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()orgetUser().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?
- How to get the access token in Supabase
- Supabase access token expiry and refresh
- Supabase personal access tokens: one token for your account
- What to do if a Supabase access token leaks
- The traps people miss with Supabase tokens
- Check what tokens open on your live app
- Questions
- Sources
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.
| Token | Looks like | Lives | Where it sits | What it opens | If it leaks |
|---|---|---|---|---|---|
| Session access token | A JWT, eyJ..., with sub, role: authenticated and session_id in its payload | 1 hour by default, then replaced | The browser or app: local storage, or cookies with server-side rendering | Your project's APIs as that one user, within your policies | That user's data, until the token expires |
| Refresh token | A random string, not a JWT | No expiry, but each one works once | Next to the access token | New access tokens, again and again | The user's session, until they sign out |
| Personal access token | sbp_..., or sbp_fc... when scoped | Until it expires or you delete it | Your terminal, CI secrets, an MCP config | The Management API as you, within its scope | Every project in its scope: delete it now |
| Anon or publishable key | eyJ... with role: anon, or sb_publishable_... | Until replaced | Your frontend, by design | The anon role, limited by RLS | Nothing to rotate: check your policies |
| Service role or secret key | eyJ... with role: service_role, or sb_secret_... | Until replaced | Your server only | Everything, skipping RLS | An emergency: replace it |
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.
// 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.
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"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.
| Method | What it checks | Use 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 |
// 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 tokenA 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:
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
}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 secret | JWT signing keys | |
|---|---|---|
| Verifying a token | Through the Auth server, or with the secret itself | Locally, with the published public keys |
| Rotating the key | Signs every active user out at once | Nobody is signed out, and each step can be reversed |
| If the key leaks | Anyone holding it can sign tokens: impersonate any user, or mint a service_role key | The 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
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
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
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.
{
"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}"
}
}
}
}What to do if a Supabase access token leaks
A personal access token
- 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
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
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
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
End the user's sessions
signOut()uses theglobalscope 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
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
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
Rotate the signing key for a wider leak
If many tokens leaked, for example in logs that kept
Authorizationheaders, 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.
// 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. Redactauthorization,cookieandapikeybefore 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 forsbp_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_KEYruns 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.Personal Access Tokens, Supabase Docs
- 2.Scope your personal access tokens, Supabase Blog
- 3.User sessions, Supabase Docs
- 4.JSON Web Token (JWT), Supabase Docs
- 5.JWT Signing Keys, Supabase Docs
- 6.Setting up Server-Side Auth for Next.js, Supabase Docs
- 7.Advanced guide: server-side auth, Supabase Docs
- 8.JavaScript reference: getClaims, Supabase Docs
- 9.JavaScript reference: signOut, Supabase Docs
- 10.JavaScript reference: admin signOut, Supabase Docs
- 11.Auth error codes, Supabase Docs
- 12.Model Context Protocol (MCP), Supabase Docs
- 13.Platform Audit Logs, Supabase Docs
- 14.Logs in Studio, Supabase Docs