You wired up Supabase, dropped USING (auth.uid() = user_id) into a policy, and the app behaves correctly in the simulator. The question that keeps surfacing on AI-coded mobile projects is whether that one line is doing the work you think it is, or whether it is a polite check on a token the user could be helping themselves to. The honest answer is yes, with conditions, and the conditions are the part most tutorials skip.
Short answer
auth.uid() is safe to use in Row Level Security policies for mobile apps when three conditions hold: the JWT is verified by PostgREST (the Supabase REST gateway) against a known signing key, every table the anon or sb_publishable_ key can reach has RLS enabled with a written policy, and the policy reads authorization data from app_metadata rather than user_metadata. The Supabase Row Level Security documentation and the JWT signing keys announcement confirm both halves of that picture.
What you should know
- auth.uid() is the verified UUID inside the JWT, not a client-asserted value. PostgREST checks the token signature before evaluating any policy, so a tampered token is rejected at the gateway.
- auth.uid() returns null when no user is authenticated. A policy of USING (auth.uid() = user_id) silently filters every row instead of erroring, which masks misconfigured clients.
- user_metadata is writable by the end user. Storing a role or tenant_id claim there and using it in an RLS policy is a known privilege-escalation path, flagged in the Supabase docs.
- app_metadata is not writable from the SDK. It is the right place for tenant or role claims, but it requires a token refresh after a change before the new value reaches auth.jwt().
- Asymmetric JWT signing became the default for new projects in October 2025. Verification happens against a public key published at /auth/v1/.well-known/jwks.json, removing the shared-secret risk of HS256.
- The service_role key bypasses RLS entirely. Inside Edge Functions or backend code that uses it, auth.uid() returns null and every policy is skipped.
How does auth.uid() actually pull the user identity out of the JWT?
auth.uid() is a SQL helper that reads the sub claim from the JWT attached to the current request. The JWT itself rides on the Authorization header set by the Supabase client SDK in the mobile app. When PostgREST receives the request, it verifies the signature, decodes the payload, and sets two Postgres GUCs (request.jwt.claims and request.jwt.claim.sub) for the duration of the connection. The SQL helper simply reads from that GUC.
That means three things have to be true for auth.uid() to be trustworthy. The token has to be signed by a key the gateway can verify, the signature check has to actually run, and the claim has to be the one PostgREST set, not one the policy author copied from a different source. PostgREST refuses the request if the signature is invalid, so the first two are handled in practice. The third is the source of most bugs: developers sometimes pass through a value from a custom header or from auth.jwt() -> 'user_metadata' ->> 'id' thinking it is equivalent to auth.uid(). That is wrong; only the verified sub claim is.
The 2025 shift to asymmetric signing keys, announced on the Supabase blog, made the verification step faster and more reliable. Edge functions and external services can now check tokens locally against the JWKS endpoint, removing the round-trip to the Auth server that previously slowed down high-traffic policies.
Why is auth.uid() = user_id so easy to get wrong?
Because the policy looks like an assertion, but it behaves like a filter. A SQL planner evaluating USING (auth.uid() = user_id) against an anonymous request substitutes null for auth.uid(), evaluates null = user_id as null (not false), and the row is excluded. The user gets an empty list, not an authentication error. From the mobile app's perspective the screen renders, the spinner stops, and the bug ships.
A safer pattern is USING (auth.uid() IS NOT NULL AND auth.uid() = user_id), recommended in the Supabase RLS reference. The IS NOT NULL clause forces the policy to fail visibly when the client is unauthenticated, which surfaces missing headers, expired tokens, and broken SDK initialisation at QA time instead of in production.
The second failure mode hides in joins. Each table's RLS policy is evaluated independently. If table A has a policy on user_id and table B does not, a query that joins A to B will leak every row of B for which a row of A passes. Community write-ups on Supabase multi-tenant projects have documented this pattern repeatedly as the most common cross-tenant leak in production projects.
What happens if I trust the user_metadata claim in a policy?
The user gains the ability to mint their own authorization. The supabase.auth.updateUser({ data: { role: 'admin' } }) call is allowed for any authenticated user, and the new value lands in raw_user_meta_data, which is the JSON object surfaced at auth.jwt() -> 'user_metadata'. A policy of the shape USING ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin') then evaluates to true for that user on the next request.
The fix is to move authorization data to app_metadata. That field is writable only through the Supabase Auth Admin API, which requires the sb_secret_ (or legacy service_role) key. The end user cannot reach it from the mobile SDK. The Supabase Row Level Security docs spell this out: data stored in app_metadata cannot be modified by the user, so it is a good place to store authorization data.
For builders who want a third-party scan of a mobile build before shipping, PTKD.com (https://ptkd.com) is one of the platforms focused on pre-submission inspection of compiled bundles and deployed Supabase origins. It flags the user_metadata-in-policy pattern when the build still ships a copy of the policy file, plus the more obvious case of a sb_secret_ key visible in the bundle.
How do the 2025 asymmetric JWT signing keys change RLS verification?
The shift removed the shared-secret model that earlier Supabase projects relied on. Until 2025, every JWT was signed with an HS256 secret known to both the Auth service and PostgREST. A leak of that secret meant any attacker could mint a valid token for any user. The new RS256 and ES256 signing flow uses a private key held only by Supabase Auth and a public key exposed at /auth/v1/.well-known/jwks.json.
| Aspect | Legacy (pre-October 2025) | Asymmetric (default since October 2025) |
|---|---|---|
| Signing algorithm | HS256 (shared secret) | RS256 or ES256 (asymmetric) |
| Verification method | Shared secret stored in PostgREST | Public key fetched from JWKS endpoint |
| Impact of signing key leak | All tokens forgeable | Only the signing service is affected, rotation is safe |
| Edge or external verification | Round-trip to Auth server | Local verification with Web Crypto API |
| Recommended client helper | supabase.auth.getUser() | supabase.auth.getClaims() |
| Migration deadline | Optional opt-in since June 2025 | Mandatory for new projects from October 2025 |
For mobile clients the practical change is small (the SDK still attaches the access token the same way) but the trust model is stronger. A compromised database server can no longer be used to mint new JWTs, and rotation of the signing key is now zero-downtime through the JWKS rollover, as the Supabase 2025 security retrospective records.
Where does auth.uid() stop being safe?
In four well-documented places. First, inside a request that runs as service_role: the role bypasses RLS, so auth.uid() returns null and every policy referencing it is skipped. Any Edge Function that calls the database with the sb_secret_ key should use an explicit user-scoped client (constructed with the user's access token) when the operation needs to be filtered by user identity.
Second, in server-side rendering frameworks that read supabase.auth.getSession() instead of getUser() or getClaims(). getSession() returns the session from local storage without re-verifying the token against the Auth server. The Supabase team documented this attack vector in a GitHub discussion: a tampered cookie or local storage entry can yield a session object whose user.id is whatever the attacker wants. RLS still protects the database, but any application code that branches on session.user.id without verification is vulnerable. On a native mobile client this is less acute (local storage is sandboxed per app) but the rule still holds.
Third, in policies that compare auth.uid() to columns of the wrong type. Postgres uuid versus text comparisons sometimes coerce; sometimes they error; sometimes they evaluate to null and silently filter the row out. Casting both sides explicitly (auth.uid() = user_id::uuid) avoids the surprise.
Fourth, in cross-table queries. RLS policies are evaluated per table, not per query. A join to a table whose policies are missing leaks rows from that table even if the originating table is protected. PTKD.com flags this in its multi-tenant audit by listing every table in the public schema and the policy count attached to each.
What to watch out for
The first trap is the test user with broad access. A developer who tests a multi-tenant app while signed in as the only seeded user never sees the cross-tenant leak, because both rows in the test database belong to the same UID. Add a second seed user before writing the first policy and the bug surfaces at the right time.
The second trap is the policy that depends on a JWT claim added by a custom hook or external auth provider. Those claims are only as trustworthy as the hook itself. If the hook reads from an external SaaS token without re-verifying the signature, the claim is effectively client-asserted. Either re-verify in the hook or store the value in app_metadata after a confirmed identity exchange.
The third trap is the assumption that asymmetric signing alone fixes a missing RLS policy. Stronger signatures keep the JWT honest, but a table with RLS disabled is still readable to anyone holding the anon key, regardless of how that key was signed. Run select tablename from pg_tables where schemaname = 'public' and rowsecurity is false after every migration; the result should be empty for a multi-tenant project.
The fourth trap is stale claims. When you bump a user's role in app_metadata, the change does not appear in their JWT until the next token refresh. A mobile session can run for an hour with the old claim. Build a forced sign-out path for sensitive role changes, or rely on a database table the policy can read directly instead of a JWT claim.
Key takeaways
- auth.uid() is safe for RLS when the JWT is verified by PostgREST, RLS is on for every table reachable with the anon key, and policies read app_metadata, not user_metadata.
- The 2025 move to asymmetric signing keys makes verification stronger and rotation safer, but it does not replace the need to write a policy for every table.
- Prefer USING (auth.uid() IS NOT NULL AND auth.uid() = user_id) so policies fail loudly on unauthenticated requests instead of returning empty result sets that look like UI bugs.
- The service_role key (and its sb_secret_ replacement) bypasses RLS entirely, so auth.uid() should never be the security boundary inside an Edge Function that uses it.
- Some teams outsource pre-submission verification of Supabase-backed mobile builds to PTKD.com (https://ptkd.com), which inspects compiled bundles for exposed secret keys and flags tables that reach the client without an RLS policy.




