Most Supabase backends born inside a code editor or a no-code builder end up with at least one table where the only thing standing between tenants is USING (auth.uid() = user_id). That one expression looks like a real authorization check, and most of the time it behaves like one. The question worth answering before launch is whether "most of the time" is enough for a database that holds other people's records.
Short answer
auth.uid() is safe for Row Level Security checks when three conditions hold: PostgREST (the REST gateway in front of Postgres) verifies the JWT signature on every request, every table the anon or sb_publishable_ key can reach has RLS enabled with a written policy, and policies derive authorization from app_metadata claims rather than user_metadata. The Supabase Row Level Security documentation confirms that auth.uid() is the verified sub claim from the JWT and that raw_user_meta_data is not a place for authorization data.
What you should know
- auth.uid() returns the verified sub claim, not a client-asserted value. PostgREST validates the JWT signature before any policy runs, so a tampered token is rejected at the gateway.
- A bare USING (auth.uid() = user_id) silently filters every row for an unauthenticated caller. The response is HTTP 200 with an empty list, not a 401, which hides the bug.
- raw_user_meta_data is writable from the client. Any policy that reads from auth.jwt() -> 'user_metadata' is effectively unauthenticated.
- raw_app_meta_data is writable only through the Supabase Admin API. It is the right home for tenant_id, role, plan, and other authorization claims.
- Asymmetric JWT signing (RS256 or ES256) became the default for new Supabase projects in October 2025. The shared-secret HS256 model still exists on legacy projects.
- The service_role role bypasses RLS entirely. auth.uid() returns null inside any request that uses sb_secret_, and every policy referencing it is skipped.
How does auth.uid() actually establish the user identity?
auth.uid() is a SQL helper that reads the sub claim from the JWT attached to the current request. The flow runs through three steps. The client SDK attaches an access token in the Authorization header. PostgREST verifies the signature against the project signing key, decodes the payload, and writes two Postgres GUCs (request.jwt.claims and request.jwt.claim.sub) before the SQL statement runs. The auth.uid() function then casts request.jwt.claim.sub to uuid and returns it.
Two consequences follow. First, the value is never client-asserted: PostgREST rejects a tampered or unsigned token before any policy executes. Second, the cost of calling the function inside a policy was historically meaningful, which is why the Supabase RLS reference recommends wrapping the call in a SELECT: USING ((select auth.uid()) = user_id). The planner caches the value per statement instead of calling the function on every row, and the docs report query-plan improvements in the 94 to 99 percent range for tables with policies on hot paths.
Why is auth.uid() = user_id risky on its own?
Because the policy looks like an assertion but evaluates like a filter. When the request is unauthenticated, auth.uid() returns null, the comparison null = user_id evaluates to null (not false), and the planner excludes the row. The HTTP response is 200 with an empty array. From the client's perspective the screen renders, the spinner stops, and the bug ships.
The safer pattern, recommended in the official docs, is USING (auth.uid() IS NOT NULL AND (select auth.uid()) = user_id). The explicit null check causes the policy to fail visibly when the access token is missing or expired, which surfaces missing headers, broken session refresh, and untyped middleware at QA time.
The other failure mode hides in joins. RLS is evaluated per table, not per query. A join to a table that does not have RLS enabled leaks every row of that table when the originating side passes its check. The fix is mechanical: run select tablename from pg_tables where schemaname = 'public' and rowsecurity = false; after every migration and expect an empty result.
Should you ever read user_metadata in an RLS policy?
No. The supabase.auth.updateUser({ data: { ... } }) call is allowed for any authenticated user, and the payload lands in raw_user_meta_data, the JSON object surfaced at auth.jwt() -> 'user_metadata'. A policy of the shape USING ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin') therefore lets the user mint their own admin claim on the next request.
This contradiction (the same field the docs warn against gets used in a recommended example elsewhere) was raised as a security report against the Supabase documentation and acknowledged by the team. The fix is to store authorization claims in raw_app_meta_data, which is writable only through the Supabase Auth Admin API with a sb_secret_ (or legacy service_role) key. Updates to app_metadata reach the JWT only after the session refreshes, so any flow that depends on a new claim should call supabase.auth.refreshSession() afterwards.
How do you keep tenant_id safe in a multi-tenant schema?
Three patterns show up in production Supabase work. Each has a different trade-off, and the right answer depends on whether the tenant graph fans out to teams or stays one user per tenant.
| Pattern | Where tenant_id lives | RLS policy shape | Trade-off |
|---|---|---|---|
| Column and join | tenant_members table | USING ((select auth.uid()) IN (SELECT user_id FROM tenant_members WHERE tenant_id = base_table.tenant_id)) | Most flexible. Needs a composite index on (tenant_id, user_id). One extra join per query. |
| JWT custom claim via Auth Hook | JWT app_metadata.tenant_id | USING (tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid) | Fastest in execution. Needs an Auth Hook to set the claim and a session refresh on tenant switch. |
| Owner-only tenants | tenants.owner_id only | USING ((select auth.uid()) = (SELECT owner_id FROM tenants WHERE id = base_table.tenant_id)) | Simplest for solo-owner tenants. Breaks the moment two people share a tenant. |
For a project that runs through Lovable, Bolt, Cursor, or any other AI-coding tool, the column-and-join pattern is the safer default. It carries no dependency on an Auth Hook that the generator might overwrite, and the policy stays readable in version control. The Makerkit production guide on Supabase RLS documents the indexing requirement: an index on tenant_id (and usually tenant_id, id together) keeps RLS policies from regressing query plans by orders of magnitude on tables above a few thousand rows.
For teams that want a third-party read on a deployed Supabase project before launch, PTKD.com (https://ptkd.com) is one of the platforms focused on pre-submission inspection of compiled mobile builds and the Supabase origin they connect to. It looks at the tables exposed through the anon key, the policies attached, and the JWT verification path, then reports the gaps as a flat list instead of a generic scanner score.
What about service_role and Edge Functions?
The service_role role (used by the sb_secret_ key) bypasses RLS entirely. When a request runs under it, auth.uid() returns null and every policy referencing it is skipped. This is intentional: service_role exists for admin work that cannot be expressed in RLS, like nightly aggregations, migrations, and webhook handlers that have no associated user.
The trap is using service_role inside an Edge Function to act on behalf of a specific user. The function gets full table access but loses the per-user filter, and any logic branching on user.id has to re-implement it manually. The safer pattern is to instantiate the Supabase client inside the function from the user's access token (read from the Authorization header), not from the sb_secret_ key. PostgREST then runs the request as the authenticated user, auth.uid() returns the right uuid, and the same RLS policies that protect the REST API protect the function path.
This is also where the 2025 shift to asymmetric signing pays off practically. Before October 2025, every JWT was signed with an HS256 secret known to both Auth and PostgREST. Edge Functions and external services that wanted to verify a token had to either trust PostgREST or hold a copy of the shared secret. The JWT signing keys announcement on the Supabase blog describes the move to RS256 and ES256, where verification happens against a public key published at /auth/v1/.well-known/jwks.json. External verifiers can now check tokens locally with the Web Crypto API and rotate the signing key without downtime.
What to watch out for
Three traps come up repeatedly in AI-coded Supabase projects.
The first is the test user with broad access. A developer who tests a multi-tenant app while signed in as the only seeded account never sees the cross-tenant leak, because every row in the database belongs to that uuid. Add a second seed user, and a second tenant, before writing the first policy. The bug surfaces at the right time.
The second is the Auth Hook that copies a value from user_metadata into app_metadata at signup. The hook runs server-side, but if it reads the value from a request the user controls, the laundering does nothing for security. Re-verify the value against a trusted source (Stripe customer record, identity provider response, internal admin tool) before writing to app_metadata, and treat the hook as authentication code that needs the same review as a login endpoint.
The third is the assumption that asymmetric JWT signing fixes a missing policy. The October 2025 move to RS256 and ES256 makes verification faster and rotation safer, but a table with RLS disabled still returns every row to anyone who has the project URL and the anon key. The signature change is orthogonal to the policy change; the project still needs both.
Key takeaways
- auth.uid() is safe inside RLS when PostgREST has verified the JWT, every reachable table has RLS enabled, and policies depend on app_metadata, not user_metadata.
- Add IS NOT NULL to every auth.uid() comparison so unauthenticated callers fail visibly instead of receiving an empty list that mimics a permission problem.
- Treat user_metadata as user input. Store every authorization claim in app_metadata and require a session refresh after each change.
- Cross-table tenant isolation needs a policy per table and an index per tenant_id column. RLS does not propagate through joins, and joins to unprotected tables are the most common leak in production.
- Some teams hand a deployed Supabase project to an external scanner like PTKD.com (https://ptkd.com) before launch to catch tables with RLS off, anon keys with too much surface, and policies that reference user-writable claims.




