How Supabase-Style Auth Works (GoTrue, JWTs, RLS)

Supabase auth works in three parts: an Auth server (originally based on Netlify's GoTrue) signs users in and issues a short-lived JWT access token plus a refresh token; every API request sends that JWT; and the API layer verifies it, switches the Postgres connection to the role in the token (anon or authenticated) and exposes the claims to SQL, where Row Level Security policies read them with auth.uid() and auth.jwt(). Your database, not your app code, decides what each user can see.

Once you understand that chain, most auth bugs in this stack become easy to find. This article follows one request from sign-in to the rows it returns. The same design is used by every backend that implements these open APIs, including StitchDB, so the code works on both.

#The pieces

PieceJob
Auth serverUsers, passwords, OAuth, magic links, MFA. Issues and refreshes tokens. Supabase's Auth repository describes it as originally based on the GoTrue codebase by Netlify, since heavily changed.
auth schemaWhere users live: auth.users, identities, sessions, refresh tokens. A normal Postgres schema in your database.
API keysThe anon (public) key and the service role (secret) key.
Access tokenA JWT the client sends with each request.
PostgRESTVerifies the JWT, sets the Postgres role and claims, runs the query.
RLS policiesSQL rules on each table that use the claims.

#Step 1: the user signs in

The client sends credentials to the Auth API. With supabase-js or StitchDB's stitchdb-js (same methods):

JavaScript
const { data, error } = await client.auth.signInWithPassword({
  email: '[email protected]',
  password: 'a-strong-password',
})
// data.session.access_token  -> a JWT, valid for one hour by default
// data.session.refresh_token -> an opaque token used to get new access tokens

Under the hood that's one HTTP call:

Terminal
curl -X POST 'https://<project-ref>.p.stitchdb.com/auth/v1/token?grant_type=password' \
  -H "apikey: <anon-key>" -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"a-strong-password"}'

The Auth server checks the password hash in auth.users, creates a row for the session, and returns the two tokens. OAuth, magic links, one-time codes, phone and anonymous sign-ins all end the same way: a session with an access token and a refresh token.

#Step 2: what's inside the access token

Decode the access token (it's base64, not encrypted) and you get claims like these:

JSON
{
  "sub": "7c1e4a9e-2b7d-4f0e-9a52-3c6d1f0b8e21",
  "role": "authenticated",
  "aud": "authenticated",
  "email": "[email protected]",
  "app_metadata": { "provider": "email", "providers": ["email"] },
  "user_metadata": { "display_name": "Ada" },
  "aal": "aal1",
  "session_id": "1f0c...",
  "is_anonymous": false,
  "iat": 1793790000,
  "exp": 1793793600
}

The claims that matter most:

  • sub: the user's id. This is what auth.uid() returns.
  • role: the Postgres role the request will run as. authenticated for signed-in users.
  • app_metadata: set only by the server. Safe to use for authorization (roles, tenant ids).
  • user_metadata: set by the user through updateUser. Never use it for authorization. Anyone can write {"is_admin": true} into their own user metadata.
  • aal: aal2 when the session passed multi-factor authentication.
  • exp: expiry. Short on purpose.

The anon key is itself a JWT, with "role": "anon" and no sub. The service role key is a JWT with "role": "service_role". That's why one mechanism handles all three cases.

#Step 3: the request reaches the API

Every request to the REST API carries two headers:

Terminal
curl 'https://<project-ref>.p.stitchdb.com/rest/v1/todos?select=id,task' \
  -H "apikey: <anon-key>" \
  -H "Authorization: Bearer <access token>"

PostgREST verifies the token's signature and expiry. Then, inside one transaction, it does roughly this:

SQL
begin;
set local role authenticated;                     -- from the "role" claim
select set_config('request.jwt.claims', '{"sub":"7c1e...","role":"authenticated", ...}', true);
select id, task from public.todos;                -- your query
commit;

Two things follow from this. First, the query runs as the authenticated database role, so ordinary Postgres grants apply: if authenticated has no select on the table, the request fails before any policy runs. Second, the claims are available to SQL for the length of the transaction.

#Step 4: RLS policies read the claims

auth.uid() and auth.jwt() are small SQL functions that read those settings. auth.uid() is roughly:

SQL
select (current_setting('request.jwt.claims', true)::jsonb ->> 'sub')::uuid;

So a policy like this:

SQL
alter table public.todos enable row level security;

create policy "Users manage their own todos" on public.todos
  for all to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

means: for requests running as authenticated, only rows whose user_id equals the token's sub are visible (using), and only such rows may be written (with check). The client just writes client.from('todos').select('*') and gets its own rows. There's no where user_id = ... in app code to forget.

For requests with only the anon key, the role is anon and auth.uid() is null, so this policy returns nothing. The service role key runs as service_role, which bypasses RLS completely. That's why it must stay on servers.

Claims beyond sub work the same way:

SQL
-- Require MFA for a sensitive table
create policy "MFA required" on public.payouts
  as restrictive for all to authenticated
  using ((auth.jwt() ->> 'aal') = 'aal2');

For a deeper look at policy design, see Row Level Security in Postgres.

#Step 5: tokens expire and refresh

Access tokens are short-lived (one hour by default) because they can't be revoked once issued: any server that can check the signature will accept the token until exp. The refresh token handles the long session:

  1. Shortly before expiry, the client library sends the refresh token to /auth/v1/token?grant_type=refresh_token.
  2. The Auth server checks the session is still valid, issues a new access token and, with rotation on, a new refresh token.
  3. The old refresh token stops working (after a short reuse interval, to tolerate two tabs refreshing at once).

Signing out revokes refresh tokens. Access tokens already issued stay valid until they expire, which is why you keep their lifetime short. The onAuthStateChange listener fires TOKEN_REFRESHED, SIGNED_OUT and so on so your UI stays in sync.

#Where it usually goes wrong

  • A table without RLS. Tables created with SQL start with RLS off. With the anon key, anyone can then read and change every row. See should you disable RLS for the safe options.
  • Authorization on user_metadata. Use app_metadata, a custom claim from an access token hook, or a table lookup instead.
  • Trusting the stored session on the server. In server code, reading the session from a cookie doesn't prove it's valid. Call auth.getUser(), which asks the Auth server, or verify the JWT signature yourself.
  • Shipping the service role key. If it's in your mobile app or frontend bundle, rotate it.
  • Views that skip RLS. A view runs with its owner's rights unless you set security_invoker = on.

#Signing keys: HS256 and asymmetric keys

The token's signature is what makes the whole chain trustworthy. Two approaches exist in this stack:

  • Shared secret (HS256). One secret signs and verifies. Any server that verifies tokens needs the secret.
  • Asymmetric keys (ES256, RS256). A private key signs; anyone can verify with the public key, published at /auth/v1/.well-known/jwks.json.

As of November 2026, Supabase recommends asymmetric signing keys, preferably ES256, keeps the legacy HS256 secret for backward compatibility, and has introduced publishable and secret API keys that are managed separately from the JWT signing key (Supabase signing keys docs). With those, its client libraries can verify tokens locally with getClaims().

StitchDB signs access tokens with HS256 using the project's JWT secret, and uses anon and service_role keys (roles and JWTs). To verify a token on your own server, copy the JWT secret from Settings → API keys:

JavaScript
import { jwtVerify } from 'jose'

const { payload } = await jwtVerify(
  token,
  new TextEncoder().encode(process.env.STITCHDB_JWT_SECRET),
)
// payload.sub is the user id, payload.role is "authenticated"

Or call auth.getUser(token) and let the Auth server check it. If you need public-key verification by third parties, Supabase's asymmetric keys are the better fit today.

#Adding your own claims

To put a tenant id or role into every token, use a "customize access token" hook: a Postgres function that runs before each token is issued. On StitchDB it's set in Authentication → Hooks (auth hooks):

SQL
create or replace function public.custom_access_token_hook(event jsonb) returns jsonb
language plpgsql stable as $$
declare claims jsonb := event->'claims';
begin
  claims := jsonb_set(claims, '{tenant_id}',
    to_jsonb((select tenant_id from public.profiles where id = (event->>'user_id')::uuid)));
  return jsonb_set(event, '{claims}', claims);
end $$;

revoke execute on function public.custom_access_token_hook(jsonb) from anon, authenticated, public;

Policies can then use auth.jwt() ->> 'tenant_id' with no extra lookup per row. Remember that claims are fixed until the token refreshes, so a role change takes up to one token lifetime to apply.

#Trying it on StitchDB

StitchDB implements the same Auth API, JWT roles and auth.uid() / auth.jwt() functions (compatibility), so everything in this article works as written. Users live in auth.users in your project's own database, sessions default to one-hour access tokens with refresh token rotation on, and you can change lifetimes in Authentication → Settings (sessions). Studio's Check access tool runs your policies as a chosen user and tells you which policy let them in or kept them out (Row Level Security docs).

#FAQ

#Is Supabase Auth the same as GoTrue?

It started from Netlify's GoTrue codebase and has diverged significantly since, according to Supabase's own repository.

#What does auth.uid() return?

The sub claim of the request's JWT: the signed-in user's id. With only the anon key it returns null.

#How long does a Supabase access token last?

One hour by default. The client library uses the refresh token to get a new one before it expires.

#Can I revoke an access token?

Not directly. Signing out revokes the refresh tokens, so no new access tokens are issued, but existing ones work until exp. Keep lifetimes short.

#Should I check permissions in my app or in RLS?

In RLS, for anything the client can reach through the API. App checks are fine for UX, but the database is the only place every request passes through.

See it with your own tables: create a free StitchDB project and read the sign-in docs.