Supabase JWT Debugger

Decode Supabase auth tokens with app_metadata, user_metadata, and AAL surfaced.

shield

Private: Decoding, analysis, signature verification, and token generation all run in your browser. Nothing is sent to any server, except a JWKS URL you explicitly enter and click Verify on.

At a glance

Trusted metadata
app_metadata (server-controlled)
Untrusted metadata
user_metadata (user-editable)
Postgres role claim
role (authenticated | anon)
Assurance level claim
aal (aal1 | aal2)
Session id claim
session_id
Default algorithm
HS256

Supabase Auth issues JWTs that mix Postgres-row identity (`role: "authenticated"`) with application identity (`app_metadata` and `user_metadata`). For multi-tenant apps, the convention is to keep tenant_id and roles in `app_metadata` (server-controlled) and never trust `user_metadata` for authorization — this debugger highlights both blocks separately so you don't confuse them.

Why Supabase tokens have two metadata blocks

Supabase splits token metadata into two blocks specifically to model trust. `app_metadata` can only be written by code that holds the service role key — your backend or an admin script — so its contents are trustworthy for authorization decisions. `user_metadata` is writable by the authenticated user themselves via the public API, which means anything in there is attacker-controlled.

The most common multi-tenant security bug is reading `user_metadata.tenant_id` and using it for Row Level Security. A user can update that to any value they like, bypassing tenant isolation entirely. This debugger highlights both blocks so the distinction is visible.

Multi-tenant patterns with Supabase

The recommended pattern is to keep `tenant_id` in `app_metadata`, then write Row Level Security policies that compare it against the row's tenant_id column. The JWT path is `auth.jwt() -> 'app_metadata' ->> 'tenant_id'`.

For users who belong to multiple tenants, store the array in `app_metadata.tenant_memberships` and the active tenant in `app_metadata.active_tenant_id`. Update `active_tenant_id` whenever the user switches tenants. RLS policies then check both: the current row's tenant_id must equal `active_tenant_id`, and `active_tenant_id` must be in the memberships array.

Frequently asked questions

What's the difference between app_metadata and user_metadata?expand_more
`app_metadata` is set by your server (via the service role key) and cannot be modified by the user. `user_metadata` can be updated by the user themselves. For authorization decisions — tenant_id, role, plan tier — always use `app_metadata`. `user_metadata` is fine for non-security display data like a preferred theme.
How do I store tenant_id in a Supabase JWT?expand_more
Set it on `app_metadata` via the Admin API: `supabase.auth.admin.updateUserById(userId, { app_metadata: { tenant_id: "..." } })`. The next session refresh will include it. Then enforce it in Row Level Security policies: `auth.jwt() -> 'app_metadata' ->> 'tenant_id' = tenant_id`.
What does `aal` mean?expand_more
`aal` stands for Authenticator Assurance Level. `aal1` is single-factor (password). `aal2` indicates the user has completed an MFA challenge in this session. Use this for step-up auth: protect sensitive routes with a check that requires `aal2`.
Why is the role claim always "authenticated"?expand_more
`role` in a Supabase JWT is the Postgres role used by PostgREST and Row Level Security — typically `authenticated` for logged-in users or `anon` for anonymous. It is *not* the application role. Keep your application roles in `app_metadata.role` to avoid confusion.

Related guides

Related Tools