Supabase access token: session JWT or personal access token, and what each can do

A Supabase access token is either the JWT that Supabase Auth issues to each signed-in user or a personal access token (sbp_...) that acts as you on the CLI, the Management API and the MCP server. The session JWT lasts 1 hour by default, and RLS reads the user from it through auth.uid(). A leaked session token exposes one user until it expires; a leaked personal token, every project in its scope.

The team behind Keybrum's attacksUpdated 13 min read

In short

  • The session access token is a JWT for one signed-in user. It lives 1 hour by default and the client replaces it with a single-use refresh token, so the session itself lasts until sign-out.
  • On a server, verify the token with getClaims() or getUser(). getSession() reads the cookie without checking it, and anyone can forge a cookie.
  • Signing a user out revokes their refresh tokens, but an access token already issued stays valid until it expires. Supabase documents no way to revoke it sooner.
  • A personal access token (sbp_...) acts as you on the Management API, the CLI and the MCP server. Scope it to one project with an expiry; a full-access token, shown with a Legacy badge, reaches every project you belong to, including future ones.
  • If a personal access token leaks, delete it on your Access Tokens page first, then read your account audit log for actions you did not take.
On this page

Which Supabase access token do you have?

Two credentials go by this name. Inside your app, the access token is the JWT (JSON Web Token: a signed string of JSON) that Supabase Auth issues to each user who signs in. The client library sends it with every request, and your row level security (RLS) policies read the user's ID from it through auth.uid(). Outside your app, the Supabase CLI, the Management API and the Supabase MCP server use a personal access token: a token starting with sbp_ that acts as you, on your Supabase account.

TokenLooks likeLivesWhere it sitsWhat it opensIf it leaks
Session access tokenA JWT, eyJ..., with sub, role: authenticated and session_id in its payload1 hour by default, then replacedThe browser or app: local storage, or cookies with server-side renderingYour project's APIs as that one user, within your policiesThat user's data, until the token expires
Refresh tokenA random string, not a JWTNo expiry, but each one works onceNext to the access tokenNew access tokens, again and againThe user's session, until they sign out
Personal access tokensbp_..., or sbp_fc... when scopedUntil it expires or you delete itYour terminal, CI secrets, an MCP configThe Management API as you, within its scopeEvery project in its scope: delete it now
Anon or publishable keyeyJ... with role: anon, or sb_publishable_...Until replacedYour frontend, by designThe anon role, limited by RLSNothing to rotate: check your policies
Service role or secret keyeyJ... with role: service_role, or sb_secret_...Until replacedYour server onlyEverything, skipping RLSAn emergency: replace it
Session tokens and legacy keys are all JWTs starting with eyJ: a session token's payload names a user (sub), an API key's only a role.

How to get the access token in Supabase

Most apps never handle it: the client library stores the session, refreshes it and attaches the access token to every query. You need the raw token only to call something that is not Supabase, such as your own API. In the browser, supabase.auth.getSession() returns it, with the refresh token and the expiry.

ts
// Browser: send the signed-in user's token to your own API
const { data: { session } } = await supabase.auth.getSession()

if (session) {
  await fetch('/api/orders', {
    headers: { Authorization: `Bearer ${session.access_token}` },
  })
}

Without a client library, a request made as a user carries your publishable key (or the legacy anon key) in apikey and the user's access token in Authorization.

bash
curl "https://YOUR_PROJECT_REF.supabase.co/rest/v1/orders?select=id&limit=5" \
  -H "apikey: YOUR_PUBLISHABLE_KEY" \
  -H "Authorization: Bearer YOUR_TEST_USER_ACCESS_TOKEN"
Run it against your own project, with a token from your own test account. The rows that come back are what your policies give that user.

On the server, verify the token before you trust it

Supabase's Next.js guide is blunt: never trust getSession() in server code. It reads the session out of the cookie without revalidating it, and anyone can forge a cookie. The three methods do not check the same thing.

MethodWhat it checksUse it for
getClaims()The token's signature, on every call: locally against the project's public keys with asymmetric signing keys (the default for new projects), through the Auth server otherwise. It also refreshes an expired token.Protecting pages, routes and data
getUser()Asks the Auth server for the current user record on every call, so it fails once the session is signed out.Sensitive actions, and the latest user data
getSession()Nothing: it returns the stored session as is.Reading the raw token to forward it, never authorization on a server
ts
// Server code: route handler, server action, proxy or middleware
const { data, error } = await supabase.auth.getClaims()
if (error || !data) {
  return new Response('Unauthorized', { status: 401 })
}
const userId = data.claims.sub // from a verified token

A backend that is not a Supabase client (an Express API, a Python worker) can verify the token with a JWT library and the public keys Supabase publishes at /auth/v1/.well-known/jwks.json:

ts
import { createRemoteJWKSet, jwtVerify } from 'jose'

const JWKS = createRemoteJWKSet(
  new URL('https://YOUR_PROJECT_REF.supabase.co/auth/v1/.well-known/jwks.json')
)

export async function userIdFrom(req: Request) {
  const token = req.headers.get('authorization')?.replace(/^Bearer /, '')
  if (!token) return null
  // Throws if the signature, the issuer or the expiry is wrong
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://YOUR_PROJECT_REF.supabase.co/auth/v1',
  })
  return payload.sub
}
This works with asymmetric signing keys. On the legacy JWT secret the endpoint returns no keys: send the token to GET /auth/v1/user instead, which Supabase recommends over verifying with the shared secret yourself.

Decoding is not verifying: jwt.decode() or jose's decodeJwt() return the claims of any token, even one written by hand. Keybrum tests this on your app's own API: it replays its test account's token with the signature removed. A route that still answers is not checking signatures.

Supabase access token expiry and refresh

Access tokens expire after 1 hour by default. You can change this under Auth settings > Sessions, but Supabase advises most apps to keep the default: more than an hour is discouraged for security reasons, and under 5 minutes causes clock skew errors and extra load on the Auth server.

The other half of the session is the refresh token: a random string with no expiry that works once, traded for a new pair before the access token runs out. A reuse within 10 seconds is tolerated, so server-side rendering can refresh on the server and then the client. Outside that window and one narrow retry case, a reuse ends the session: Supabase revokes all its refresh tokens, assuming one was copied.

So by default a session lasts until the user signs out or changes their password. On Pro plans and above, you can time-box sessions, add an inactivity timeout or keep one session per user; each limit is checked at the next refresh, so it takes effect up to one access token lifetime late.

Signing keys: the legacy JWT secret and asymmetric keys

Older projects sign access tokens with the legacy JWT secret, a shared HS256 secret that also signed the legacy anon and service_role keys. New projects use asymmetric JWT signing keys by default (Supabase recommends ES256): anyone can check a token with the published public keys, without being able to sign one.

Legacy JWT secretJWT signing keys
Verifying a tokenThrough the Auth server, or with the secret itselfLocally, with the published public keys
Rotating the keySigns every active user out at onceNobody is signed out, and each step can be reversed
If the key leaksAnyone holding it can sign tokens: impersonate any user, or mint a service_role keyThe private key cannot be extracted from Supabase, and a revoked key leaves the published list

To migrate, open the JWT Keys page in your project settings, click Migrate JWT secret, then Rotate keys: new tokens get the new key while issued ones stay valid. With a 1 hour expiry, Supabase says to wait at least 1 hour 15 minutes before revoking the legacy secret. First check that none of your code verifies tokens with the legacy secret directly, or it breaks.

Supabase personal access tokens: one token for your account

A personal access token authenticates you to the Management API and the tools built on it, the Supabase CLI and the MCP server. You create them on your account's Access Tokens page (supabase.com/dashboard/account/tokens). Tokens created there are scoped by default and start with sbp_fc: you pick the organizations, the projects, a permission level per resource (Database, Edge Functions, API Keys and so on) and an expiry up to one year out. A token never gets more than your own role allows, its scope cannot be changed later, and its value is shown once. The Read-only preset includes API key secrets, so Supabase suggests starting from No access.

Full-access tokens carry a Legacy badge, and supabase login in a browser creates one. They hold every permission on every organization and project you belong to, including ones you create or join later. Depending on its permissions, a token can read your projects' secret keys, change Auth settings, or pause and delete a project; a full-access token can do all of it, everywhere.

Using a personal access token in CI

  1. 1

    One scoped token per job

    Scope it to the project the workflow deploys, with an expiry and only what its commands need: supabase link, for example, needs Project Settings, API Keys and API Key Secrets, all with Read access.

  2. 2

    Store it as a CI secret

    Expose it to the job as SUPABASE_ACCESS_TOKEN, which the CLI reads before any saved token. Never write the value in the workflow file or print it.

  3. 3

    Protect the database password too

    Commands that connect with your database password (SUPABASE_DB_PASSWORD) are not limited by the token's permissions, so a scoped token does not make a leaked password harmless.

Using it with the Supabase MCP server

The hosted MCP server at https://mcp.supabase.com/mcp signs you in through the browser by default. In CI it takes a personal token in the Authorization header and can only use the tools that token's permissions allow. Three URL parameters narrow what a coding agent can do: project_ref limits it to one project and turns off the account tools, read_only=true runs every query as a read-only Postgres user, and features enables only the tool groups you list. Supabase asks for all three, and a review of the prompt injection risk, before you connect a production project.

json
{
  "mcpServers": {
    "supabase": {
      "type": "http",
      "url": "https://mcp.supabase.com/mcp?project_ref=YOUR_PROJECT_REF&read_only=true&features=database,docs",
      "headers": {
        "Authorization": "Bearer ${SUPABASE_ACCESS_TOKEN}"
      }
    }
  }
}
The token comes from an environment variable, as in Supabase's own example, so it never sits in a file you commit. Check that your MCP client expands variables in its config.

What to do if a Supabase access token leaks

A personal access token

  1. 1

    Delete it now

    Delete it on your Access Tokens page. A token works until it expires or is deleted, so removing it from a repository, a chat or a CI log does nothing about copies already taken.

  2. 2

    Replace it with a scoped token

    Create a new token for that job, scoped to one project with a short expiry, and update the CI secret or MCP config.

  3. 3

    Read the audit log

    Your Account audit logs (supabase.com/dashboard/account/audit) list the actions taken with your account, with IP address, token type, action and target; on Team and Enterprise plans, the organization audit log covers every member. Look for actions and addresses that are not yours.

  4. 4

    Assume what it could read was read

    If the token had full access or could read API key secrets, treat those projects' secret keys as leaked and follow the service role key guide. Revert any setting you did not change.

A user's session token

  1. 1

    End the user's sessions

    signOut() uses the global scope by default and ends the user's sessions on every device. From your server, auth.admin.signOut() does the same with the leaked token and a client created with a secret key. Both revoke the refresh tokens.

  2. 2

    Wait out the access token

    Supabase's reference is explicit: there is no way to revoke an access token until it expires, at most an hour with the default expiry. If that is too long for your data, lower the expiry.

  3. 3

    Check what it did

    In the dashboard Logs, start the time range when the token leaked and add a User filter with the user's ID: it matches Auth events and API Gateway requests made with their token. How far back you can look depends on your plan.

  4. 4

    Rotate the signing key for a wider leak

    If many tokens leaked, for example in logs that kept Authorization headers, rotate your signing keys and revoke the previous key. Revoking early signs active users out, and Supabase's edge caches the public key list for 10 minutes. Logs that kept cookies also kept refresh tokens: sign those users out too. If the legacy JWT secret itself leaked, that is the service role emergency.

ts
// In the app, as the user: ends their sessions on every device
await supabase.auth.signOut({ scope: 'global' })

// On your server, with a client created with a secret key
await supabaseAdmin.auth.admin.signOut(leakedAccessToken, 'global')

The traps people miss with Supabase tokens

  • Tokens that JavaScript can read. Without server-side rendering the session sits in local storage; with it, in cookies that Supabase says need not be HttpOnly, since the browser needs the refresh token. Either way, one cross-site scripting (XSS) bug reads both tokens, and refresh token reuse detection does not protect a session stolen from the device. Prevent the XSS: no user content rendered as raw HTML, and a Content Security Policy (see security headers).
  • Authorization headers in logs. Request loggers, error trackers and a quick console.log(req.headers) keep bearer tokens, and Supabase names logged cookies, request bodies and URL parameters as ways refresh tokens leak. Redact authorization, cookie and apikey before anything is written.
  • A token pasted where others can read it. An AI chat, a terminal screenshot, a debug log in a public GitHub issue, an MCP config committed with the token inline. Delete the token: editing the message is not enough. The leaked key guide has the order of operations.
  • A personal token in the frontend. An sbp_ token in a JavaScript bundle gives a stranger your Supabase account, not one app. Keybrum looks for sbp_ tokens in your JavaScript and confirms one with a read-only call that lists projects.
  • A session token used as an API key. A token copied from the developer tools into SUPABASE_KEY runs every request as that user until it expires, then the app breaks. The app needs the publishable key. See anon key vs publishable key.
  • A valid token is not an ownership check. A verified token proves who the user is, not that the row is theirs. Policies must compare auth.uid() with an owner column, or any signed-in stranger reads everyone's rows. The RLS guide has the policies; the IDOR guide covers the same bug in your own API routes.

Check what tokens open on your live app

A stranger tests your app signed out, then signed in with a fresh account of their own, then with a token whose signature was removed. The free check starts where they do, with what anyone loading your app sees.

Questions

How do I get the access token in Supabase?

In the browser, call supabase.auth.getSession(): the session it returns holds access_token, refresh_token and the expiry. Use it to forward the token to your own API. On a server, verify the token with getClaims() or getUser() before you trust the user it names.

How long does a Supabase access token last?

A session access token lasts one hour by default, changed under Auth settings > Sessions; Supabase discourages more than an hour and less than five minutes. The client renews it with a single-use refresh token, so the session lasts until sign-out unless you set session limits, which need a Pro plan or above. A personal access token lasts until its expiry date or until you delete it.

Where do I find my Supabase personal access token?

Create one on your account's Access Tokens page, at supabase.com/dashboard/account/tokens. Supabase shows the value only once, so if you lost it, create a new token and delete the old one. Tokens created there are scoped by default and start with sbp_fc; running supabase login in a browser creates a full-access one.

Is the Supabase access token the same as the anon key?

No. The anon key, or the publishable key that replaces it, identifies your project and is public by design. The access token identifies one signed-in user and travels next to the key in the Authorization header. Your RLS policies see that user through auth.uid().

Does signing out invalidate a Supabase JWT?

It revokes the refresh tokens, so the session cannot be renewed. The access token already issued stays valid until it expires, which is why Supabase advises against long expiry times. getUser() rejects it as soon as the session is gone.

Can I decode a Supabase JWT?

Yes: the payload is base64url-encoded JSON, so any JWT decoder shows the user ID, role, session ID and expiry. Decoding proves nothing about who sent it. Verify the signature with getClaims() or a JWT library and your project's public keys before you trust any claim.

Sources

  1. 1.Personal Access Tokens, Supabase Docs
  2. 2.Scope your personal access tokens, Supabase Blog
  3. 3.User sessions, Supabase Docs
  4. 4.JSON Web Token (JWT), Supabase Docs
  5. 5.JWT Signing Keys, Supabase Docs
  6. 6.Setting up Server-Side Auth for Next.js, Supabase Docs
  7. 7.Advanced guide: server-side auth, Supabase Docs
  8. 8.JavaScript reference: getClaims, Supabase Docs
  9. 9.JavaScript reference: signOut, Supabase Docs
  10. 10.JavaScript reference: admin signOut, Supabase Docs
  11. 11.Auth error codes, Supabase Docs
  12. 12.Model Context Protocol (MCP), Supabase Docs
  13. 13.Platform Audit Logs, Supabase Docs
  14. 14.Logs in Studio, Supabase Docs

Your app is public. So are its mistakes.

Check what any attacker can already see in it, free, in ten seconds. The full attack, with proof and fixes: €49 once, or €29 a month on a plan.

or continue with Google or GitHub