← All posts
·9 min read·GuardLayer

AWS Cognito + Supabase RLS: setup and gotchas

SupabaseRLSCognitoAuthPostgres

TL;DR: AWS Cognito is one of Supabase's five natively supported third-party auth providers, so it works with Row Level Security the same way Clerk, Auth0, Firebase, and WorkOS do — register Cognito's issuer, wire a supabase-js accessToken() callback, and read verified claims with auth.jwt(). Cognito's twist is a good one: its sub claim is UUID-shaped, so auth.uid() actually works in practice here — unlike the other providers, whose non-uuid sub throws invalid input syntax for type uuid. Even so, store user_id as text and compare (select auth.jwt() ->> 'sub') for a portable, cast-free policy. And Cognito tokens still need a literal role: authenticated claim, or every query silently returns empty.

Why doesn't my AWS Cognito user work with Supabase RLS?

By default Supabase won't trust a JWT that Cognito minted, so the request runs unauthenticated, RLS denies it, and you get empty results or a 401. The fix is to register your Cognito user pool as a native third-party auth provider — Cognito is one of exactly five providers Supabase supports natively (alongside Clerk, Auth0, Firebase, and WorkOS), so you get the real integration, not the server-side-role workaround that unsupported libraries like NextAuth or better-auth need. Once registered, Supabase accepts Cognito's RS256-signed tokens the way it accepts its own. But trusting the token is only half the job: it still has to carry the claims RLS reads.

How do I set up Cognito with Supabase RLS?

Three pieces: register the issuer, make sure the token carries role: authenticated, and send the token from the client.

1. Register Cognito in Supabase. Dashboard → Authentication → Third-Party Auth → Add provider, and register your Cognito user pool as a trusted token issuer. Cognito's issuer URL — the iss claim on its tokens — has the shape:

https://cognito-idp.<region>.amazonaws.com/<userPoolId>

For local dev and CI, mirror the provider under an [auth.third_party.*] block in supabase/config.toml:

[auth.third_party.cognito]
enabled = true
# Cognito's issuer/region keys are NOT the same as Auth0's tenant/tenant_region
# or Firebase's project_id — copy the exact field names from Supabase's own
# Cognito third-party-auth docs rather than guessing them.

2. Make sure the token carries a role: authenticated claim. Like every native provider, Supabase requires a literal role claim set to authenticated; without it PostgREST drops into the anon role and every TO authenticated policy matches nothing. Cognito's documented mechanism for adding or overriding token claims is a Pre Token Generation Lambda trigger — but confirm the exact requirement against Supabase's Cognito docs, because each of the five providers injects this claim a little differently.

3. Send the Cognito token from the client. Build the supabase-js v2 client with the async accessToken() option returning the Cognito JWT as a bare string. On every request supabase-js sends it as Authorization: Bearer:

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  {
    async accessToken() {
      // The Cognito token that carries your `role` claim, as a bare string.
      return (await getCognitoToken()) ?? null;
    },
  }
);

Don't also call supabase.auth.setSession or mix supabase.auth.signInWith… on the same client — the accessToken callback is the whole integration.

Why does auth.uid() actually work with Cognito?

Here's the Cognito differentiator, and it's a pleasant one. Supabase's auth.uid() helper reads the sub claim and casts it to uuid. Cognito renders its sub as a canonical 36-character UUID string, so that cast succeeds and auth.uid() returns your user's id — exactly the thing that breaks for the other providers. (One honest caveat: AWS's own docs say Cognito's sub "doesn't conform to a specific UUID format, including RFC UUID" and warn against validating it as one — the cast works here only because Postgres is lenient about UUID version/variant bits, not because AWS guarantees a UUID. It's stable in practice, but that's one more reason the cast-free text pattern below is the safer default.) Auth0 subjects (auth0|…), Clerk ids (user_2…), and Firebase UIDs (28-character strings) all throw invalid input syntax for type uuid, and WorkOS ids (user_01H…) are prefixed strings too.

So with Cognito you could write auth.uid() = user_id against a uuid column. But the robust, provider-portable pattern is still to store user_id as text and compare (select auth.jwt() ->> 'sub') — no cast anywhere. It reads the same verified sub, it never surprises you if you switch providers, and it sidesteps a subtle trap: never model user_id as a uuid foreign key to auth.users. Cognito users don't live in auth.users — that table only holds native Supabase Auth users — even though the sub happens to be a uuid.

What's the correct RLS policy pattern?

create table documents (
  id      bigint generated always as identity primary key,
  title   text not null,
  user_id text not null default auth.jwt() ->> 'sub'   -- Cognito sub (UUID-shaped), stored as text
);

alter table documents enable row level security;

create policy "Users manage their own documents"
on public.documents
for all
to authenticated
using      ( (select auth.jwt() ->> 'sub') = user_id )
with check ( (select auth.jwt() ->> 'sub') = user_id );

Three load-bearing details, identical to the sibling providers: text on both sides (no cast), TO authenticated (which only matches because of the role claim), and the (select auth.jwt() …) wrapping.

Why wrap it in (select …)? The InitPlan win

Wrapping the auth call in a scalar sub-select turns it into an uncorrelated subquery, which Postgres plans as an InitPlan — evaluated once per statement instead of once per row. A bare auth.jwt() in the predicate re-parses the JWT JSON for every row scanned. Supabase's published benchmarks on a 100K-row table make the gap concrete: auth.uid() = user_id drops from 179 ms to 9 ms just by wrapping the call; adding an index on the policy column takes it from 171 ms to under 0.1 ms; and scoping TO authenticated lets Postgres skip the policy entirely for anon (170 ms → under 0.1 ms). The full breakdown is in RLS performance.

The silent-empty trap, and how to actually test it

If your Cognito queries come back empty with no error, the token is almost certainly missing "role": "authenticated" — the policy is being evaluated for anon and matches nothing. There's no exception because, as far as Postgres is concerned, you simply own no rows.

Confirm from the database side, on a real authenticated request:

select auth.jwt() ->> 'role', auth.jwt() ->> 'sub';

You want authenticated and your Cognito UUID. Critically, the Supabase SQL editor is not a valid test surface: it runs as a privileged role with no request JWT, so auth.jwt() is NULL there and tells you nothing. Test through a real request, or simulate one with set_config('request.jwt.claims', …) inside a rolled-back transaction.

Don't reach for service_role or disable RLS to "make Cognito work"

When RLS returns empty and the cause is a config problem you can't see, the tempting shortcut is to route the query through the service_role key or run alter table … disable row level security. Both remove enforcement entirely. The service_role key bypasses RLS completely — it is subject to no policy at all — so using it for user-facing reads or writes means every user can touch every row; it belongs only in trusted server code, never the client (see service_role key exposed). And disabling RLS strips the net from the whole table. Neither "fixes" Cognito; both just hide the broken integration behind an open door.

One real edge if you do use service_role for legitimate server inserts: a service_role request carries no JWT, so auth.jwt() is NULL server-side. A user_id text not null default auth.jwt() ->> 'sub' column then throws a not-null violation on those writes. Set user_id explicitly in server code for service_role inserts.

The one static thing GuardLayer can catch here: grant-to-anon

Almost everything above is runtime — but there's one static failure mode worth pinning down. A 2026 Supabase Data API change (changelog #45329) means tables in exposed schemas reach the auto-generated Data/GraphQL API only via explicit GRANTs to anon/authenticated. New projects have defaulted to this since 2026-05-30; existing projects flip on 2026-10-30. The old blanket GRANT … ON ALL TABLES IN SCHEMA public is gone for new tables — so an explicit GRANT … TO anon on a table that never enables RLS is now a deliberate public re-exposure.

That's exactly what GuardLayer's supabase/grant-to-anon rule (severity warning, CWE-732) flags: a public table GRANTed to anon with no enable row level security in the same migration. It's a static rule — it reads your SQL migrations, not runtime state.

Where GuardLayer fits (and where it doesn't)

Be clear-eyed: GuardLayer is a static scanner. It doesn't validate your Cognito↔Supabase integration, read runtime JWT claims, or see query plans — so it cannot tell you why your Cognito RLS returns empty. That's a runtime/config problem, and this post is the fix.

What it does catch is the static shortcut people reach for when they're stuck: RLS turned off on a table, USING (true) and other over-permissive policies, policies with no user-scoping predicate at all, and the new grant-to-anon re-exposure. Get Cognito + RLS working correctly instead of removing the safety net.

FAQ

Does auth.uid() work with AWS Cognito? Yes — this is the Cognito difference. Cognito renders its sub as a canonical UUID string, and auth.uid() casts sub to uuid, so the cast succeeds (AWS doesn't formally guarantee RFC-UUID conformance, but Postgres's lenient cast accepts it). It's the one native provider where auth.uid() doesn't throw. Even so, comparing (select auth.jwt() ->> 'sub') against a text column is the more portable pattern.

My Cognito queries return empty with no error — why? The token is missing "role": "authenticated", so PostgREST runs the request as anon and your TO authenticated policies match nothing. Add the claim via Cognito's Pre Token Generation Lambda trigger (confirm the exact requirement in Supabase's Cognito docs), and verify with select auth.jwt() ->> 'role' on a real request.

Can I store the Cognito user id in a uuid column referencing auth.users? You can use a uuid column since the sub is a uuid, but never make it a foreign key to auth.users — Cognito users don't exist in that table; it only holds native Supabase Auth users. Storing user_id as text and comparing auth.jwt() ->> 'sub' avoids the question entirely.

Why does auth.jwt() return NULL in the SQL editor? The Supabase SQL editor runs as a privileged role with no request JWT attached, so auth.jwt() is NULL there regardless of your setup. It is not a valid test surface — verify RLS through a real authenticated request, or simulate one with set_config('request.jwt.claims', …) in a rolled-back transaction.

Is Cognito set up differently from Clerk, Auth0, or Firebase? The native third-party-auth mechanism is identical — register the issuer, wire the accessToken callback, read auth.jwt(). The quirks differ: Auth0 makes you send the ID token, Firebase's first token lacks the role claim, and Cognito's happy difference is that its sub is a real UUID. WorkOS is the other native provider, and unlike Cognito its ids are prefixed strings, not uuids.

Catch this before it ships — free

GuardLayer scans every push for this and 24 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading