Security Model Overview
Core Principles
- Workspace Isolation: All workspace-scoped resources are isolated via RLS policies
- Permission-Based Access: Actions require specific workspace role permissions
- User Ownership: Users can only access resources they own or have permission to view
- Fail-Secure Default: No access unless explicitly granted by policy
Architecture
Policy Patterns
Pattern 1: Workspace Membership Check
Use Case: User must be a member of the workspace to access resources. Example:workspace_tasks SELECT policy
auth.uid(): Gets the authenticated user’s ID- Subquery checks
workspace_memberstable - Returns only rows matching user’s workspaces
Pattern 2: Permission-Based Access
Use Case: User needs specific workspace permission to perform action. Example: Task UPDATE policymanage_infrastructure_settingsmanage_workspace_settingsmanage_workspace_securitymanage_usersmanage_user_groupsmanage_user_rolesmanage_financemanage_calendarmanage_documentsmanage_inventoryai_lab_assistantview_disabled_usersdisable_user
Pattern 3: User Ownership
Use Case: User can only access resources they created. Example:notes policies
Pattern 4: Combined Workspace + Permission
Use Case: Workspace membership AND specific permission required. Example: Calendar event managementPattern 5: Public Read, Restricted Write
Use Case: All workspace members can view, only privileged users can modify. Example: Workspace settingsPattern 6: Cross-Table Permission Inheritance
Use Case: Permission check depends on related table’s policies. Example: Task project members inherit from project permissionsPattern 7: Platform User Profile Visibility
Use Case: Platform profile rows are useful for names and avatars, but the Data API must not let any authenticated user enumerate every account.public.users is not a globally readable directory. Authenticated callers can
read their own row and rows for users who share at least one workspace with
them. Private account fields such as email and full name belong in
public.user_private_details. Account state and preferences such as
services, timezone, first_day_of_week, time_format, and deleted also
belong there instead of on the public profile row.
public.user_private_details may be used for self-profile and shared-workspace
profile lookups, but the policy must correlate the target user to the current
requesting user. Do not use an uncorrelated EXISTS check that only proves the
target user belongs to some workspace.
Use a SECURITY DEFINER helper for the shared-workspace check so the profile
policy does not recurse through workspace_members RLS under the caller’s
permissions.
Do not restore a policy equivalent to USING (true) on public.users. If a
feature needs a global user directory or public profile page, expose only the
intended columns through an application route or a purpose-built API contract
instead of widening the table policy.
Pattern 8: Client-Facing Public Views
Use Case: A view in the exposedpublic schema is granted to anon or
authenticated.
Postgres views are security definer by default when created by privileged
owners. In Supabase, that means a public view can bypass RLS on the underlying
tables even when every base table has RLS enabled. Any public-schema view that
is directly selectable by anon or authenticated must be created or altered
with security_invoker = true.
Do not use a public view as a shortcut around table policies. If a view needs
privileged data such as audit records or private profile details, revoke direct
client-role SELECT and expose the data through a permission-checked API route
or RPC instead.
public-view-security.sql, which fails
when a Data API-exposed view is missing security_invoker.
Pattern 9: Workspace-Scoped Security Definer RPCs
Use Case: An RPC must run with elevated database privileges to read audit, private, or RLS-protected tables. Every workspace-scopedSECURITY DEFINER RPC that is reachable through the
Supabase Data API must enforce authorization inside the database function. Page,
route, or server action permission checks are not sufficient because clients can
call exposed RPCs directly.
Required controls:
- Read the caller with
auth.uid()and deny missing callers unless the JWT role isservice_role. - Verify workspace membership and the workspace permission in the function before reading sensitive rows. Do not rely on permission helpers alone when a default permission could be enabled workspace-wide.
- For identity-linking flows, resolve candidate identity inside the function
from the current verified
auth.usersrow. Gate privileged lookups with the workspace feature config before returning candidate IDs, and do not accept caller-supplied emails or user-editable profile fields as proof of identity. - Revoke direct execution from
publicandanon. - Avoid exposing lower-level privileged helper functions to
authenticatedwhen they do not perform their own permission check. - Cover the grant matrix and allowed/denied calls with pgTAP when practical.
Pattern 10: API-Only Workspace Tables
Use Case: Theapps/web API performs stricter permission checks than a
plain workspace membership policy can express safely, then reads or writes with
createAdminClient().
For these tables, do not grant anon or authenticated direct Data API access
and do not leave member-only RLS policies in place. A browser or REST client can
call exposed Supabase tables directly with the publishable key, bypassing route
checks such as manage_users or send_user_group_post_emails.
Required controls:
- Revoke table privileges from
public,anon, andauthenticated. - Keep table access behind service-role API routes that perform the workspace permission check first.
- Keep service-role grants explicit. The proxy route is the public contract; the table is not a browser, mobile, REST, or GraphQL contract.
- Drop permissive member-only RLS policies on the underlying tables; use service-role-only policies when an explicit policy is useful for review.
- Add the tables to
PROXY_ONLY_PUBLIC_TABLESinpackages/supabase/src/next/protected-tables.tsso deprecated direct clients fail loudly during app development. - Revoke direct
EXECUTEfrom exposed helper RPCs unless the function performs its own caller authorization.
20260522172925_harden_proxy_only_public_tables.sql applies this
transition guardrail to the current PROXY_ONLY_PUBLIC_TABLES set and also
revokes automatic grants for future public tables, functions, and sequences.
New migrations that intentionally expose a public Data API surface must now add
the matching explicit GRANT statements in the same migration.
Pattern 11: Private Schema Server-Owned Tables
Use Case: A table, helper function, or protected query surface is only needed by cron jobs, service-role routes, Tuturuuu API routes, or database internals and should not be exposed through Supabase’s generated REST API at all. Default new protected database access to the existingprivate schema unless
the data is intentionally public. Do not add private to the Supabase Data API
exposed schemas or extra search path. New migrations should prefer direct
server-owned access from apps/web: call private RPCs through the server-side
Supabase admin client with schema('private').rpc(...), and use
getPlatformSql() only for private table access that is not modeled as an RPC.
This keeps browser and mobile consumers on centralized apps/web API routes
while avoiding new Supabase REST endpoints for private data.
Legacy public bridging RPCs may exist while older code is migrated, but do
not add new public RPCs for tables or protected helper queries that can be
reached through the server-side database connection.
If a public table has no app/package consumers and is not part of an active
database contract, prefer dropping it in a forward migration instead of moving
dead schema surface into private.
Notification delivery queue internals follow this pattern:
private.notification_batches and private.notification_delivery_log are
processed only by the server-owned batch cron, immediate-send route, and
service-role notification helper functions.
Required controls:
- Keep
anonandauthenticatedwithoutUSAGEonprivate. - Revoke direct table privileges from
public,anon, andauthenticated. - Keep RLS enabled with service-role-only policies for reviewability.
- Keep private table reads and writes inside server-only modules used by
apps/webroutes, server actions, or server components. - Cover private placement, table privileges, and schema usage with pgTAP tests.
Common RLS Helpers
Get User Workspaces
Check Workspace Permission
Policy Testing
Enable RLS (Always Required)
Test Policy as User
Verify Policy Coverage
Security Best Practices
1. Always Enable RLS
2. Use SECURITY DEFINER Carefully
Only useSECURITY DEFINER for helper functions that need elevated privileges:
3. Test Policies Thoroughly
Always test:- ✅ Users can access their own data
- ✅ Users cannot access other workspaces’ data
- ✅ Permission checks work correctly
- ✅ Edge cases (pending invites, disabled users)
4. Avoid Policy Conflicts
5. Performance Considerations
Common Policy Patterns by Table Type
Workspace-Scoped Resources
User-Owned Resources
Public Read Resources
Debugging RLS Issues
Check Active Policies
Test as Specific User
Bypass RLS for Admin Operations
UsecreateAdminClient() from @tuturuuu/supabase in server-side code:
createAdminClient() locals sbAdmin, not supabase, so service-role access is visually distinct from request-scoped clients during review.
Migration Example
When adding a new table, always include RLS:Privilege Grants vs RLS Scope
RLS policies only apply after SQL privileges have already allowed the role to touch a table. For sensitive workspace configuration tables, avoid grantinginsert, update, or delete to the broad authenticated role when the
matching RLS policies only check workspace membership.
Keep authenticated grants read-only unless the table policy itself enforces
the same granular permission that the application route requires. Route
privileged writes through permission-gated API handlers using elevated
service-role access.