Security

    Is supabase.auth.uid() safe for RLS checks in mobile apps?

    Diagram of a Supabase mobile request showing a JWT being verified by PostgREST and an RLS policy that reads auth.uid() from the verified sub claim

    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.

    AspectLegacy (pre-October 2025)Asymmetric (default since October 2025)
    Signing algorithmHS256 (shared secret)RS256 or ES256 (asymmetric)
    Verification methodShared secret stored in PostgRESTPublic key fetched from JWKS endpoint
    Impact of signing key leakAll tokens forgeableOnly the signing service is affected, rotation is safe
    Edge or external verificationRound-trip to Auth serverLocal verification with Web Crypto API
    Recommended client helpersupabase.auth.getUser()supabase.auth.getClaims()
    Migration deadlineOptional opt-in since June 2025Mandatory 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.
    • #supabase
    • #auth.uid
    • #row level security
    • #rls
    • #mobile auth
    • #jwt
    • #ai-coded apps

    Frequently asked questions

    Does auth.uid() actually verify the JWT signature, or does it trust whatever the client sends?
    PostgREST verifies the JWT signature before any row is touched. The token is decoded, the signature is checked against the project signing key (HS256 for legacy projects, JWKS-published asymmetric keys since October 2025), and only then does auth.uid() return a UUID. A forged or tampered token is rejected at the gateway, never reaching the policy. If verification ever fails silently, the leak is in your backend, not in Supabase.
    Can a mobile user modify their own user_metadata to escalate privileges in an RLS policy?
    Yes, that is the standard pitfall. The raw_user_meta_data field is writable by the authenticated user through supabase.auth.updateUser(), so a policy like USING (auth.jwt()->'user_metadata'->>'role' = 'admin') is effectively unauthenticated. Store role and tenant identifiers in raw_app_meta_data, which is writable only via the Admin API with a sb_secret_ key. The Supabase RLS guide flags this explicitly.
    Why does my RLS policy still work even when the user is not signed in?
    Because auth.uid() returns null for an unauthenticated request, and SQL evaluates null = column as null, which the policy planner treats as false. The rows are filtered out, but the check is brittle. Prefer USING (auth.uid() IS NOT NULL AND auth.uid() = user_id) so the policy fails loudly on misconfigured clients instead of returning an empty list that looks like a permission problem at the UI.
    If RLS is enabled, do I still need to scrub the anon key from my mobile bundle?
    The anon key is designed to ship with the bundle, so embedding it is fine. The risk is forgetting that RLS, not the anon key, is the actual boundary. A leaked anon key plus a table without an RLS policy returns every row in that table to anyone with the project URL. Treat the anon key as public; treat each table without a written policy as already breached.
    Is auth.uid() trustworthy in an Edge Function or a backend that uses the service_role key?
    No, in the sense that auth.uid() returns null whenever the request runs under the service_role role. Calls made with sb_secret_ or service_role bypass RLS entirely, so any policy referencing auth.uid() is skipped. In a backend that needs to act as a specific user, create a Supabase client with the user's access token attached, not the service key, then auth.uid() resolves to that user's UUID.

    Keep reading

    Scan your app in minutes

    Upload an APK, AAB, or IPA. PTKD returns an OWASP-aligned report with copy-paste fixes.

    Try PTKD free