# Sessions, JWTs and refresh tokens

> How Supabase Auth sessions work on SupaHost: access token lifetime (300 to 604800 seconds), the claims inside the JWT, refresh tokens, signing out, and verifying tokens on your own server.

Source: https://supahost.app/docs/auth/sessions · Updated 2026-11-11

A Supabase session is a pair of tokens:

- An **access token**: a short-lived JWT sent with every request. The Data API, Storage, Realtime and Edge Functions read the user's identity and role from it, and your RLS policies read its claims.
- A **refresh token**: a long-lived, single-use string the client exchanges for a new access token (and a new refresh token) before the old access token expires.

The client libraries handle the refresh for you. In the browser, supabase-js refreshes in the background while the tab is open. On servers, `@supabase/ssr` refreshes when a request arrives with an expired or nearly expired token (see [Server-side auth](/docs/auth/server-side)).

## Access token lifetime

Set it in *Dashboard → your project → **Auth** tab → **Sign-in & security** → **Access token lifetime***, in seconds:

| | Value |
|---|---|
| Default | 3600 (one hour) |
| Minimum | 300 (five minutes) |
| Maximum | 604800 (seven days) |

Click **Save sign-in settings**; the new value applies to tokens issued from about a minute later. Tokens already issued keep their original expiry.

Shorter lifetimes mean changes such as a removed role or a banned user take effect sooner, because the old token stops working sooner. They also mean more refresh requests. One hour suits most apps. Going much lower rarely helps, and going much higher means a leaked token stays useful for longer.

The access token lifetime isn't the session length. A user stays signed in as long as their client keeps refreshing, which can be weeks or months. SupaHost has no setting for a maximum session length or an inactivity timeout, so sessions last until the user signs out, you sign them out, or the user is deleted.

## What's in the access token

Decoded, a SupaHost access token looks like this:

```json
{
  "iss": "https://app-one.acme.supahost.space/auth/v1",
  "sub": "8c3e6f1a-2b4d-4c9e-9f10-5a7b3c2d1e0f",
  "aud": "authenticated",
  "exp": 1794567600,
  "iat": 1794564000,
  "role": "authenticated",
  "email": "dana@example.com",
  "aal": "aal1",
  "amr": [{ "method": "password", "timestamp": 1794564000 }],
  "session_id": "1f2e3d4c-5b6a-4789-8a9b-0c1d2e3f4a5b",
  "is_anonymous": false,
  "app_metadata": { "provider": "email", "providers": ["email"] },
  "user_metadata": { "display_name": "Dana" }
}
```

- `sub` is the user id, which `auth.uid()` returns in SQL.
- `role` decides the Postgres role for the request (`authenticated` for signed-in users).
- `aal` is `aal2` once the user has passed an [MFA](/docs/auth/mfa) check.
- `iss` always uses the project's SupaHost host, even after you add a custom domain.

Read claims in policies with `auth.jwt()`:

```sql
create policy "Only confirmed MFA sessions see payouts"
on public.payouts for select
to authenticated
using ((select auth.jwt() ->> 'aal') = 'aal2' and (select auth.uid()) = user_id);
```

The `anon` and `service_role` API keys are JWTs too, with `iss: "supahost"` and `role` set to `anon` or `service_role`. They aren't sessions and don't expire for ten years. See [API keys](/docs/api/api-keys).

## Refresh tokens

Each refresh token can be used once. Using it returns a new access token and a new refresh token, and the old one is retired. Auth allows a short grace period (GoTrue's default is 10 seconds) in which the same refresh token can be presented again, to cover two tabs refreshing at once or a response lost on a flaky network.

If an old refresh token turns up after that window, Auth treats it as possible theft and revokes the whole session. In practice this happens most often when two parts of the same app refresh independently, for example a server middleware and a browser client using different cookie or storage setups. If users are signed out "at random", look for that first.

## Signing out

```ts
await supabase.auth.signOut()                     // every session of this user (default scope 'global')
await supabase.auth.signOut({ scope: 'local' })   // only the session on this device
await supabase.auth.signOut({ scope: 'others' })  // every session except this one
```

Signing out revokes the refresh tokens, so the sessions can't be renewed. Access tokens already issued stay valid until they expire, because they're checked by signature, not looked up. Keep the access token lifetime modest if you need sign-out to bite quickly.

To sign a user out of everything from your server (after support resets their password, say): if you hold one of their access tokens, `admin.auth.admin.signOut(accessToken, 'global')` revokes all their sessions. Without a token, deleting the user's rows from `auth.sessions` in the SQL editor has the same effect, because their refresh tokens are removed with them. See [Managing users](/docs/auth/user-management).

## Reacting to session changes

```ts
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
  // INITIAL_SESSION, SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, USER_UPDATED, PASSWORD_RECOVERY
  if (event === 'SIGNED_OUT') clearLocalData()
})

// later
subscription.unsubscribe()
```

Keep the callback quick and don't `await` other Supabase calls inside it; schedule them with `setTimeout` instead, or you can deadlock the client.

## Verifying tokens on your own server

If a separate backend receives a user's access token (in an `Authorization: Bearer` header from your app), verify it before trusting it. The simplest way works with any SupaHost project:

```ts
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)

export async function userFromRequest(req: Request) {
  const token = req.headers.get('authorization')?.replace(/^Bearer /, '')
  if (!token) return null
  const { data, error } = await supabase.auth.getUser(token)
  return error ? null : data.user
}
```

SupaHost projects sign tokens with HS256, a shared secret. The JWT secret isn't shown in the dashboard and can't be rotated, so verify through Auth (`getUser(token)` or `getClaims(token)`) rather than with a JWT library. In Edge Functions, the "Require JWT" option rejects requests without a valid project JWT, but the `anon` key counts as one, so still look up the user inside the function. See [Authentication in Edge Functions](/docs/functions/auth).

## Related

- [JWT expired](/kb/jwt-expired)
- [PGRST303: JWT issued at future](/kb/jwt-issued-at-future)
- [Should I shorten cookie Max-Age?](/kb/cookie-max-age)
- [Rotating API keys and the JWT secret](/kb/rotating-jwt-secret)
