← All posts
·8 min read·GuardLayer

"Session from session_id claim in JWT does not exist"

SupabaseAuthJWTRLSNext.js

Session from session_id claim in JWT does not exist is a 403 from Supabase Auth: the access token is validly signed, but the session it names was deleted by a sign-out, a "sign out everywhere", or a user deletion. The error is the good outcome. The risk is that PostgREST and RLS never ask that question, so the same token keeps reading and writing your tables until it expires. Keep expiry short, call getUser() before sensitive actions, and add a session check to RLS if you need hard revocation.

Most people meet this string in a server log or a network tab after someone signed out on another device, or after an account was deleted while its owner still had a tab open. It isn't a bug: Auth is telling you the token outlived its session.

What does "Session from session_id claim in JWT does not exist" mean?

It means the session_id inside the access token points at a row in auth.sessions that is gone. Every Supabase access token carries that claim. The Supabase sessions guide puts it this way:

Every access token contains a session_id claim, a UUID, uniquely identifying the session of the user. You can correlate this ID with the primary key of the auth.sessions table.

When a request hits an Auth endpoint that needs a logged-in user (/user, /logout and friends), the Auth server first verifies the signature, then loads the user from the sub claim and the session from session_id. That logic lives in maybeLoadUserOrSession in internal/api/auth.go, and it can fail in two ways:

Error messageerror_codeWhat's missing
User from sub claim in JWT does not existuser_not_foundThe user row was deleted
Session from session_id claim in JWT does not existsession_not_foundThe session row was deleted

Both come back as HTTP 403 with a body shaped like {"code":403,"error_code":"session_not_found","msg":"..."}.

Two things usually delete sessions. signOut() removes the current session, all of them, or all but the current one depending on scope. auth.admin.deleteUser() removes the user, and auth.sessions has on delete cascade to auth.users, so every session goes with it.

One supabase-js detail: recent versions map session_not_found to AuthSessionMissingError and clear the local session, so getUser() may hand you "Auth session missing!" instead of the raw string. user_not_found has no such mapping and surfaces as AuthApiError: User from sub claim in JWT does not exist. Raw fetch calls, the network tab, and client libraries without that mapping show the server message as-is.

Does signOut() revoke the Supabase JWT?

No. signOut() deletes sessions and revokes refresh tokens. It cannot touch an access token that has already been issued. The signOut reference says so directly:

There is no way to revoke a user's access token jwt until it expires.

Scope only decides which sessions get deleted. The docs note "the default scope is 'global'", so a bare signOut() signs the user out of every device:

await supabase.auth.signOut();                     // global: every session
await supabase.auth.signOut({ scope: "local" });   // this session only
await supabase.auth.signOut({ scope: "others" });  // every session except this one

After a global sign-out, the other devices can't refresh, and their next call to an Auth endpoint gets the 403 above. Until then, their access tokens are still perfectly valid JWTs.

Why the old token still works against your database

PostgREST, and therefore every RLS policy, checks a JWT the way any JWT consumer does: is the signature valid, and is exp in the future? It does not query auth.sessions. auth.uid() reads sub from the token and that's the end of it.

getClaims() behaves the same way. With asymmetric signing keys it verifies the signature against your cached JWKS locally and never asks Auth whether the session still exists. That's why it's fast, and it's the trade-off covered in getSession vs getUser vs getClaims. getUser() is the only one of the three that would have produced this error.

So the gap looks like this:

  • A user hits "sign out everywhere" because a laptop was stolen. The laptop's access token still reads and writes their rows through RLS until it expires.
  • You delete an account. The deleted user's open tab can still call PostgREST with the old token. Policies built on auth.uid() = user_id will match any rows that survived the delete, which is one more reason to get the foreign keys and cascade rules on user deletion right.

The window is the JWT expiry. The sessions guide says "Most applications should use the default expiration time of 1 hour." Once it lapses, PostgREST rejects the token with the 401 described in Supabase JWT expired (PGRST301), and the refresh token that would have renewed it is already gone.

How do I revoke a Supabase session immediately?

You can't revoke the token itself, but you can stop accepting it. Pick the layer that matches how sensitive the action is.

1. Keep JWT expiry short. This bounds the window for everything. Supabase's own guidance: "We do not recommend going below 5 minutes for the JWT expiration time." Somewhere between 5 minutes and the 1-hour default is the practical range.

2. Call getUser() before sensitive mutations. Delete account, change email or password, billing changes, admin actions. getClaims() is fine for rendering a dashboard; it is not proof that the session still exists.

// app/account/actions.ts
"use server";

import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin"; // server-only, secret key

export async function deleteAccount() {
  const supabase = await createClient();

  // Round trip to Auth: fails if this session was signed out elsewhere.
  const { data: { user }, error } = await supabase.auth.getUser();
  if (error || !user) redirect("/login");

  const admin = createAdminClient();
  const { error: deleteError } = await admin.auth.admin.deleteUser(user.id);
  if (deleteError) throw deleteError;

  // Sessions are already gone (cascade). This just clears this browser's cookies.
  await supabase.auth.signOut({ scope: "local" });
  redirect("/goodbye");
}

If you need to sign a user out from the server without deleting them, auth.admin.signOut(jwt, scope) takes their access token and a scope and calls the same logout endpoint. It needs a token whose user still exists, so it's not something to run after deleteUser.

3. Advanced: make RLS check the session. The sessions guide's answer for strong guarantees is "You can check that the session_id claim in the JWT corresponds to a row in the auth.sessions table", and it adds that you should "use this validation logic only for the most sensitive actions within your application." The authenticated role can't select from auth.sessions, so the check goes in a SECURITY DEFINER function in a schema the Data API doesn't expose:

create schema if not exists private;

create or replace function private.session_is_active()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select exists (
    select 1
    from auth.sessions s
    where s.id = (auth.jwt() ->> 'session_id')::uuid
      and s.user_id = auth.uid()
  );
$$;

revoke execute on function private.session_is_active() from public;
grant usage on schema private to authenticated;
grant execute on function private.session_is_active() to authenticated;

-- Restrictive: ANDed with your existing policies on this table.
create policy "require a live session"
on public.payouts
as restrictive
for all
to authenticated
using ((select private.session_is_active()))
with check ((select private.session_is_active()));

Wrapping the call in (select ...) lets Postgres evaluate it once per statement instead of once per row, and the lookup is a primary-key hit. It is still an extra query on every request to that table, so apply it to the few tables that matter and measure. Keep it out of public: a SECURITY DEFINER function there is callable by anyone through RPC.

Quick self-check

# Sensitive server actions gated only by getClaims()?
grep -rn "getClaims" app/ --include="actions.ts" --include="route.ts"

# Account deletion that never verifies the session first?
grep -rln "admin.deleteUser" app/ lib/ | xargs grep -L "getUser"

Then open Auth settings and look at the JWT expiry. If someone raised it to a day to "fix" logouts, that day is your revocation window.

FAQ

Is "Session from session_id claim in JWT does not exist" a bug in Supabase? No. It means the session was deleted, usually by a sign-out or a user deletion, and Auth correctly refused the token. Sign the user out locally and send them to log in again.

What does "User from sub claim in JWT does not exist" mean? The user in the token's sub claim was deleted. It shows up when a deleted user's browser still holds a token and calls getUser(), updateUser() or signOut().

Does deleting a user with auth.admin.deleteUser() log them out? It deletes their sessions and refresh tokens, so they can't refresh. Their current access token still verifies on PostgREST until it expires.

What is the default signOut() scope in Supabase? global. A plain signOut() ends every session for that user. Pass { scope: "local" } to sign out only the current device.

Does banning a user stop their access token? On Auth endpoints, yes: the Auth server rejects banned users who still hold a token. On PostgREST, no. The token is checked for signature and expiry only, so the same window applies.

Check your Supabase Auth settings and tables, free

Connect your Supabase project read-only. GuardLayer checks your Auth settings (leaked passwords, email confirmation, secure email change, token rotation, CAPTCHA, MFA) and your RLS, policies and grants, and says how to fix each problem. It never reads your rows and can't change anything.

Check my Supabase project

Prefer to check code instead? Scan your repo, no signup.

Keep reading