HomeGuidesSupabase signOut scopes and session revocation, tested

Guide

Supabase signOut scopes and session revocation, tested

Supabase signOut() is global by default, and access tokens keep working after it. What local, others, password and email changes revoke, tested.

16 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

You asked Cursor, Lovable, Bolt or Claude Code for a "Log out" button on Supabase, and maybe a "Sign out of all devices" one in account settings. Both are one line: supabase.auth.signOut(). The trouble is what that line actually does. In supabase-js it signs the user out of every device by default, which surprises people who wanted a normal logout. And even when you want everything gone, the access tokens on the other devices keep working until they expire.

This guide answers the questions behind "supabase signout all devices" and "supabase access token still valid after logout": what each signOut scope deletes, what the other devices can still do, what a password change, email change, ban or account deletion revokes, and how to close the gap for the tables that matter. For the wider problem of removing someone from a team, see revoking a removed team member's access.

Tested on October 30, 2026. We ran the open source Supabase Auth server built from source (commit c3348e8) with its migrations on PostgreSQL 17.10, PostgREST 16.5 for the Data API, a local mail catcher, and @supabase/supabase-js with auth-js 2.118.0 on Node 24.22.1. We ran it with asymmetric ES256 signing keys (what Supabase's signing keys guide recommends over the legacy secret) and again with the legacy HS256 secret. We did not test on a hosted Supabase project, so hosted defaults and the dashboard come from Supabase's docs, not from us. VibeLocked is independent and not affiliated with Supabase.

The short version

  1. signOut() is global by default in supabase-js. One logout on a phone ends the laptop session too. Use { scope: 'local' } for a normal Log out button.
  2. { scope: 'others' } is the "sign out other devices" button. It keeps the current session and fires no SIGNED_OUT event.
  3. No scope revokes an access token already issued. It keeps reading and writing through RLS until exp, plus up to 30 seconds of clock skew in PostgREST.
  4. The other device does not notice. Its refresh fails, but auth-js keeps the session while the access token is still valid, so the UI looks signed in.
  5. A password change ends every other session. An email change ends none. A ban keeps the sessions too, it only blocks refresh.
  6. signOut() with Auth unreachable clears the local session and leaves the server session alive. The refresh token still works if someone copied it.
  7. A restrictive RLS policy that checks session_id against auth.sessions closes the gap for tables that need it. Add the ban check yourself.
  8. The default 1 hour JWT expiry is the real window. Shorten it if an hour is too long, but Supabase advises not going below 5 minutes.

1. What each signOut scope deletes

Supabase's sign out guide lists three scopes: global (the default), local and others. The signOut reference warns that the default scope is 'global' and "signs the user out of every device they are currently signed in on," and recommends { scope: 'local' } "if you only want to sign the user out of the current session (the behavior most other auth libraries default to)." The guide adds that JavaScript, Swift, Python and C# default to global, while Dart and Kotlin default to local.

In the Auth server's logout.go, each scope is one DELETE on auth.sessions: by user id for global, by session id for local, and by user id except the current session for others. Refresh tokens are deleted with their session.

We signed one user in on three devices, each with a private row in a table protected by user_id = auth.uid(), and called signOut from device 1:

From device 1 auth.sessions rows (3 before) Device 1 refresh Devices 2 and 3 refresh Device 1 local session
signOut() (no options) 0 refresh_token_not_found refresh_token_not_found Cleared
signOut({ scope: 'global' }) 0 refresh_token_not_found refresh_token_not_found Cleared
signOut({ scope: 'local' }) 2 refresh_token_not_found Still work Cleared
signOut({ scope: 'others' }) 1 Still works refresh_token_not_found Kept

So if your users complain that logging out on one device logs them out everywhere, this is why. If you added a "sign out of all devices" button and it calls signOut() with no options, it does what it says, but so does your normal Log out button.

2. The access token keeps working after every scope

A session is two tokens. The refresh token lives in auth.sessions and can be deleted. The access token is a signed JWT that the Data API trusts on its own. The signOut reference is direct: "There is no way to revoke a user's access token jwt until it expires." The sign out guide says the same: access tokens of revoked sessions "remain valid until their expiry time."

Here is what each signed out device's old access token could still do in our test, right after the sign out:

Check with the old access token Result
GET /rest/v1/notes (RLS on auth.uid()) 200 with the private row
POST /rest/v1/notes 201, row inserted
supabase.auth.getUser(token) AuthSessionMissingError (raw /user: 403 session_not_found)
supabase.auth.getClaims(token), ES256 keys Claims returned, no error
supabase.auth.getClaims(token), legacy HS256 secret AuthSessionMissingError (it falls back to getUser())

That is the difference our getSession vs getUser vs getClaims guide describes: getClaims() verifies the signature locally against your JWKS and never asks Auth whether the session still exists. If your Next.js proxy or Server Actions use getClaims(), a signed out token passes them until it expires. Use getUser() for the actions where that matters, such as deleting an account, changing billing, or showing other people's data.

The 30 second extra. With the JWT expiry set to 30 seconds, the old token still got 200 from PostgREST 25 seconds after its exp, and 401 PGRST303 JWT expired at 32 seconds. That matches the PostgREST docs, which allow "a 30-second clock skew" on exp. So the real window is the JWT expiry plus about 30 seconds. We measured this on PostgREST itself, not on the hosted Data API.

3. The other device still looks signed in

After a global or others sign out, the other devices do not get a push. Their tab finds out only when it tries to refresh. In our test, calling refreshSession() on device 2 failed with refresh_token_not_found, but its local session was still present afterwards and no SIGNED_OUT event fired.

That is deliberate in auth-js 2.118.0. In _callRefreshToken, when a refresh fails for a non-network reason while the stored access token has not expired yet, it keeps the session (the source comment calls this a proactive refresh that failed while the access token is still valid) and only removes it once the access token is past exp. The result is a device that shows the app as signed in, and can still read data, for up to the JWT expiry.

Two more details from the reference and our test:

  • others fires no SIGNED_OUT in the calling tab. The reference says so and we saw only INITIAL_SESSION. That is right, since the current session survives, but do not wait for that event to show "other devices signed out."
  • local and global do fire SIGNED_OUT in the tab that called them, and clear its storage.

If your UI must react fast on other devices, check on focus with getUser() and sign the tab out locally when it returns an auth error.

4. Signing out while Auth is unreachable

We stopped the Auth server and called signOut() on a signed in client. It returned AuthRetryableFetchError (status 502 from our local proxy), and the local session was cleared anyway. When Auth came back, the session row was still in auth.sessions, and the refresh token from before the sign out still minted a new access token.

Look at the source and it is the expected behavior: auth-js removes the local session for any scope except others, even when the logout request fails, so the user is not stuck signed in on the device. On a shared or lost device that is fine. If a token was already stolen, it is not. Show the error when signOut returns one and offer to retry, and for a "sign out everywhere" action make sure the call succeeded before you tell the user it worked.

5. What password change, email change, ban and delete revoke

Supabase's sessions guide says a session ends when "the user changes their password or performs a security sensitive action." We checked which actions that covers, with two or three devices signed in each time:

Action Other sessions Session that made the change Old access tokens on the Data API
updateUser({ password }) Deleted, refresh fails Kept, refresh works Still work until expiry
auth.admin.updateUserById(id, { password }) All deleted (server call) Still work until expiry
updateUser({ email }), secure email change, both links clicked Kept, refresh works Kept Work
auth.admin.updateUserById(id, { ban_duration: '24h' }) Kept in auth.sessions (server call) Still work until expiry
auth.admin.signOut(userJwt, 'global') All deleted (server call) Still work until expiry
auth.admin.deleteUser(id) All deleted (server call) Accepted, but our table deleted their rows on cascade

Details:

  • Password change. In user.go and the user model, UpdatePassword calls "log out user from all other sessions" when a session is attached and "log out user from all sessions" when not (admin change, reset via link). The old password stopped working. Our password reset guide has the reset link side.
  • Email change. No sessions were deleted, and the final confirmation link added one: the count went from 2 to 3. If an email change could be an attacker's move, call signOut({ scope: 'others' }) after it, as our change email guide explains.
  • Ban. Refresh returned 400 user_banned and /user returned 403 user_banned, but the sessions stayed and the Data API kept serving the token. Supabase's user management docs also note that banning does not revoke sessions.
  • auth.admin.signOut needs the user's own JWT, which your server usually does not have. The team member guide has a server-only function that deletes a user's sessions by id instead.

6. Close the gap in RLS for the tables that need it

The sessions guide's answer to "how to ensure an access token cannot be used after a user signs out" is to "check that the session_id claim in the JWT corresponds to a row in the auth.sessions table," and to use it "only for the most sensitive actions." Here is that check as a restrictive policy, with the ban check added because a banned user's sessions are not deleted:

create schema if not exists private;

create or replace function private.session_is_valid()
returns boolean
language sql stable security definer set search_path = ''
as $$
  select exists (
    select 1
    from auth.sessions s
    join auth.users u on u.id = s.user_id
    where s.id = nullif(auth.jwt() ->> 'session_id', '')::uuid
      and (u.banned_until is null or u.banned_until <= now())
      and u.deleted_at is null
  )
$$;

revoke all on function private.session_is_valid() from public, anon;
grant usage on schema private to authenticated;
grant execute on function private.session_is_valid() to authenticated;

-- Restrictive: added on top of your normal owner policy, never instead of it.
create policy "session must be valid"
  on public.payment_methods
  as restrictive
  for all
  to authenticated
  using ((select private.session_is_valid()))
  with check ((select private.session_is_valid()));

What we saw with the old token after a global sign out:

Table Read Insert
Owner policy only 200, 1 row 201
Owner policy + session_is_active (sessions only) 200, 0 rows 403 42501
Owner policy + session_is_valid (sessions + ban) 200, 0 rows 403 42501

For a banned user, the sessions-only version still returned the row (the session exists), and the version with the ban check returned 0 rows.

Limits we found:

  • Time-boxed and inactive sessions are not deleted. With a 20 second session time-box, the refresh failed with session_expired after 24 seconds, but the row was still in auth.sessions and the session check let the token through. The sessions guide says such sessions are "cleaned up progressively 24 hours" later. If you rely on the time-box or inactivity timeout (Pro plan and up), the JWT expiry is still your bound.
  • It is a lookup on every query that touches the table. (select ...) makes Postgres run it once per statement, not once per row. Keep it to the tables where a signed out token reading data for an hour is a real problem.
  • It does not cover Storage signed URLs, Realtime connections or routes that use the secret key. Those have their own checks, listed in the team member guide.
  • Server code that uses getClaims() needs the same idea: call getUser() for sensitive actions.

The function is security definer so the authenticated role never needs read access to auth.sessions. Our security definer guide covers why the empty search_path and the revokes matter.

7. JWT expiry is the real window

Every gap above lasts as long as the access token. The sessions guide says "most applications should use the default expiration time of 1 hour," discourages going over an hour, and says values "below 5 minutes, and especially below 2 minutes, should not be used in most situations" because of refresh load and clock skew. Our test at 30 seconds worked, but it was a lab setting, not advice.

A sensible set: keep 1 hour or lower it to 5 to 15 minutes if your app holds payment, health or other high-risk data, and add the session check above to those tables. You change it in the Auth settings under Sessions (dashboard path from Supabase's docs, not something we clicked through). See Supabase Auth security settings for the rest of that page.

8. Build the buttons the right way

// Log out button: this device only.
await supabase.auth.signOut({ scope: 'local' })

// Account settings: "Sign out of other devices".
const { error } = await supabase.auth.signOut({ scope: 'others' })
if (error) {
  // Do not tell the user it worked.
  showError('Could not sign out other devices. Try again.')
} else {
  showNotice('Other devices are signed out. Open tabs there can keep working for up to an hour.')
}

// After a successful email change, end the other sessions yourself.
await supabase.auth.signOut({ scope: 'others' })

After a password change you do not need to call anything: Auth already ended the other sessions. A short notice that other devices were signed out is still kind to the user, and an email on password change (Supabase can send one) is how they find out if it was not them. Do not show your users raw tokens or ask them to copy anything from the browser to "fix" a session.

What we tested and what we did not

Tested (scripts and outputs kept with our notes): signOut() with no options, global, local and others across three sessions; auth.sessions row counts; refresh with each device's refresh token; Data API read and insert with each old access token; getUser() and getClaims() with that token on ES256 keys and on the legacy HS256 secret; onAuthStateChange events for others and local; the other device's refreshSession() and whether its local session survived; signOut() while the Auth server was down, then refresh after it came back; updateUser({ password }), admin password change, secure email change with both links, ban, auth.admin.signOut and deleteUser; the session check policy with and without the ban check, for reads and inserts; a 30 second JWT expiry against PostgREST's clock skew; and a 20 second session time-box.

Not tested: a hosted Supabase project, the dashboard and its defaults, the hosted Data API's clock skew, Supabase's single session per user and inactivity timeout settings, MFA and SSO sessions, the Dart, Kotlin, Swift, Python and C# clients, @supabase/ssr cookie handling in a real Next.js app, Realtime and Storage after sign out, and how much the session check costs on large tables.

Error codes you will see

Code Where
refresh_token_not_found Refresh after the session was signed out, deleted or ended by a password change
session_not_found /user (so getUser()) with a token whose session no longer exists; auth-js returns AuthSessionMissingError
session_expired Refresh after the time-box or inactivity timeout
user_banned Refresh or /user for a banned user
PGRST303 Data API: "JWT expired", after exp plus about 30 seconds
42501 RLS refused a write, for example by the session check

The full list is in Supabase's Auth error codes.

Checklist

  • The Log out button uses signOut({ scope: 'local' }), unless you really want global
  • "Sign out of other devices" uses signOut({ scope: 'others' }) and checks error
  • A failed signOut shows an error instead of a success message
  • After an email change, the app calls signOut({ scope: 'others' })
  • Sensitive server actions call getUser(), not only getClaims() or getSession()
  • Sensitive tables have a restrictive session_id policy that also checks bans
  • Offboarding deletes the user's sessions, not only sets a ban
  • The JWT expiry is 1 hour or less, and no lower than 5 minutes
  • The UI rechecks the session on focus for screens that show sensitive data

Copy-paste prompt for your AI coding tool

Audit sign out and session revocation in this Supabase app. Do not print or ask for any keys or tokens. Find every call to supabase.auth.signOut, auth.admin.signOut, updateUser, admin.updateUserById and deleteUser, every use of getSession, getClaims and getUser on the server, and every RLS policy on tables with payment, personal or admin data. Flag: a normal Log out button that calls signOut() with no scope (it is global by default); a "sign out other devices" button that does not use scope 'others' or ignores the returned error; email change flows that do not sign out other sessions afterwards; server actions for account deletion, billing, role changes or data export that trust getClaims or getSession instead of getUser; offboarding that only bans a user; and sensitive tables without a restrictive policy checking the JWT session_id against auth.sessions. Propose: scope 'local' for Log out, scope 'others' with error handling for the settings button, a private.session_is_valid() security definer function (sessions joined to users, banned_until and deleted_at checked, execute granted only to authenticated) used in restrictive policies on the sensitive tables, getUser in sensitive server actions, and the JWT expiry at 1 hour or lower but not below 5 minutes. Show the diff before applying it.

More prompts: security review prompt for AI coding tools.

Check what your app exposes with LeakCheck

LeakCheck scans a live app URL or a public GitHub repo, read only. It looks for exposed keys such as a Supabase service role key in your bundle, which would let anyone skip every RLS policy in this guide, and for tables the public key can read. It does not sign in, so it cannot see your signOut scopes, your JWT expiry or whether an old token still reads data. Those checks are yours to run with the tests above.

Sources

Questions

Does supabase.auth.signOut() sign the user out of all devices?

Yes, in supabase-js the default scope is global. In our test, signOut() with no options on one of three devices deleted all three rows in auth.sessions, and the other two devices got 400 refresh_token_not_found on their next refresh. Pass { scope: 'local' } to end only the current session, or { scope: 'others' } to end every session except the current one. Dart and Kotlin default to local, per Supabase's sign out guide.

Is a Supabase access token still valid after logout?

Yes, until its exp claim. Signing out deletes the session and its refresh tokens, but the access token is a signed JWT and the Data API only checks its signature and expiry. In our test a token from a signed out session still read and wrote rows through RLS, and PostgREST accepted it for up to 30 seconds past exp because it allows that much clock skew. getUser() rejected it straight away; getClaims() with asymmetric signing keys still returned the claims.

How do I log out other sessions in Supabase?

Call supabase.auth.signOut({ scope: 'others' }) from the session you want to keep. In our test it deleted every other row in auth.sessions for that user, kept the current one working, and did not fire a SIGNED_OUT event in the calling tab. The other devices kept their access tokens until expiry, so pair it with a short JWT expiry or a session check in RLS for sensitive tables.

Does changing the password or email sign out other devices in Supabase?

A password change does: updateUser({ password }) deleted every other session and kept the one that made the change, and an admin password change through updateUserById deleted all of them. An email change does not: after a secure email change both old sessions still refreshed, and the confirmation link added a new session. Banning a user also left the sessions in place; only their refresh and getUser() were refused.

How do I make RLS reject a token after the user signs out?

Add a restrictive policy that calls a security definer function checking that the token's session_id claim still exists in auth.sessions, as Supabase's sessions guide describes. In our test it turned a signed out token's reads into zero rows and its inserts into 42501. Join auth.users in the same function to also block banned users. It costs a lookup per query, so use it on the tables that need it and keep the default 1 hour JWT expiry or shorter.