SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
May 17, 2026·Security·SecureStartKit Team

Supabase JWT + Session Management in Next.js [2026]

Supabase JWT lifecycle, ES256 asymmetric signing keys, httpOnly cookie storage, and getClaims vs getUser vs getSession for Next.js apps.

Summarize with AI

On this page

  • Table of contents
  • What's inside a Supabase JWT?
  • How does the access-token + refresh-token lifecycle work?
  • Where should the JWT live in a Next.js app?
  • getSession vs getUser vs getClaims: which one to call
  • Asymmetric JWT signing keys: what changed in 2025-2026
  • Custom JWT claims via the Custom Access Token Hook
  • When to force a session refresh
  • Five JWT and session mistakes that ship security holes
  • What this means for your Next.js + Supabase architecture

On this page

  • Table of contents
  • What's inside a Supabase JWT?
  • How does the access-token + refresh-token lifecycle work?
  • Where should the JWT live in a Next.js app?
  • getSession vs getUser vs getClaims: which one to call
  • Asymmetric JWT signing keys: what changed in 2025-2026
  • Custom JWT claims via the Custom Access Token Hook
  • When to force a session refresh
  • Five JWT and session mistakes that ship security holes
  • What this means for your Next.js + Supabase architecture

Supabase JWT and session management in Next.js comes down to three architectural choices: where the JWT lives (httpOnly cookies via @supabase/ssr, never localStorage), how the signature gets validated (ES256 asymmetric signing keys with getClaims() for local verification), and when the session refreshes (in middleware/proxy.ts and Server Actions, never trusted from a stale read). Get those three right and the JWT becomes a cryptographic primitive you can lean on; get any of them wrong and the entire auth boundary collapses to "trust the cookie."

This pillar covers the JWT lifecycle as it works in Supabase Auth's 2026 form: ES256 signing keys (default for new projects since October 2025) [1], the getSession vs getUser vs getClaims tradeoff that catches most projects off guard, the refresh-token rotation pattern, and the integration points with Custom Access Token Hooks for RBAC + multi-tenancy. The free JWT decoder and JWT generator are the cluster's CORE tools for inspecting and testing JWTs against the patterns below.

TL;DR:

  • ES256 is the new default. Supabase moved to asymmetric JWT signing keys (ECDSA P-256) for new projects in October 2025 [1]. The JWKS endpoint at <project>.supabase.co/auth/v1/jwks exposes the public key for local verification. Legacy HS256 (shared secret) projects can migrate via the dashboard.
  • getClaims() for authorization, almost always. Validates the JWT signature locally against the cached JWKS via WebCrypto, no network round trip [5]. Same cryptographic guarantee as getUser() (which hits the Supabase Auth API) without the latency.
  • getSession() is unsafe for authorization. Reads from the cookie without re-validating the signature. The Supabase docs explicitly discourage its use for authorization since the asymmetric-keys release [3].
  • Access tokens are 1h by default; refresh tokens are single-use. Refresh exchanges one refresh token for a new access+refresh pair [4]. Refreshes happen in middleware/proxy.ts, in Server Actions, and on the cookie write path. They cannot happen inside Server Components (which can't set cookies).
  • httpOnly cookies, always. @supabase/ssr writes the JWT to cookies with httpOnly, Secure, SameSite=Lax defaults. localStorage makes the JWT readable by any inline script, which is an XSS escalation path that the 3-layer injection defense won't catch.

Table of contents

  • What's inside a Supabase JWT?
  • How does the access-token + refresh-token lifecycle work?
  • Where should the JWT live in a Next.js app?
  • getSession vs getUser vs getClaims: which one to call
  • Asymmetric JWT signing keys: what changed in 2025-2026
  • Custom JWT claims via the Custom Access Token Hook
  • When to force a session refresh
  • Five JWT and session mistakes that ship security holes
  • What this means for your Next.js + Supabase architecture

What's inside a Supabase JWT?

A Supabase JWT is a standard three-part JSON Web Token: header, payload, signature, base64url-encoded and dot-separated. Paste any real Supabase access token into the free JWT decoder to see the structure live.

The interesting half is the payload (the "claims"). Supabase populates these out of the box [2]:

{
  "aud": "authenticated",
  "exp": 1717094400,
  "iat": 1717090800,
  "iss": "https://your-project.supabase.co/auth/v1",
  "sub": "8af2e7b1-...",
  "email": "user@example.com",
  "phone": "",
  "app_metadata": { "provider": "email", "providers": ["email"] },
  "user_metadata": { "full_name": "User Name" },
  "role": "authenticated",
  "aal": "aal1",
  "amr": [{ "method": "password", "timestamp": 1717090800 }],
  "session_id": "..."
}

Three claims do most of the work in application code:

  • sub is the user's UUID. It's the identifier you join against in auth.users.id and that every user_id foreign key in your schema references. Never trust email or user_metadata.email for identity; sub is the canonical primary key.
  • role is the Postgres role the request executes as via PostgREST. For an authenticated user it's authenticated; for an unauthenticated request it's anon. RLS policies dispatch off this.
  • exp is the expiry, a Unix timestamp. Past exp and the JWT is rejected by every Supabase client regardless of signature validity. Default lifetime is 1 hour [4].

Two more claims matter for security posture but rarely get touched in application code:

  • aal (Authentication Assurance Level) reflects MFA state: aal1 for password-only, aal2 after a second factor. RLS policies can require aal2 for sensitive operations.
  • amr (Authentication Methods Reference) records which factors were used. Useful for audit logs.

Custom claims (tenant_id, user_role, plan tier, anything else your app needs) get added via the Custom Access Token Hook, covered later in this post and in depth in the multi-tenancy and RBAC pillar.

How does the access-token + refresh-token lifecycle work?

Supabase issues two tokens per session, and they have different jobs [4]:

The access token is the JWT. It carries the claims, gets sent on every request, and authorizes the operation. Short-lived: default 1 hour, configurable down to about 5 minutes in the dashboard. The shorter the lifetime, the smaller the blast radius if a JWT leaks.

The refresh token is an opaque random string (not a JWT). Single-use: when exchanged it produces a new access+refresh pair, and the old refresh token is immediately invalidated. Never expires on its own, but rotates on every refresh. This means a stolen refresh token works exactly once, after which it's revoked.

The lifecycle looks like this:

  1. User signs in. Supabase Auth returns { access_token, refresh_token, expires_at }.
  2. The Next.js app stores both in httpOnly cookies (the @supabase/ssr client does this automatically).
  3. Every request to a Supabase endpoint includes the access token in the Authorization: Bearer ... header.
  4. When expires_at approaches (or has passed), the client calls auth.refreshSession(), sends the refresh token to /auth/v1/token?grant_type=refresh_token, and gets back a new access+refresh pair.
  5. Both new tokens get written back to the cookies via Set-Cookie headers on the response.

In Next.js specifically, step 4 happens in three places [6]:

  • proxy.ts (or middleware.ts in Next.js 15-) runs before every matching route. The @supabase/ssr createServerClient call refreshes if needed and writes the updated cookies to the response.
  • Server Actions run server-side. When they touch the Supabase client and a refresh fires, the cookies get updated on the action's response.
  • Route Handlers behave like Server Actions for the cookie-write purpose.

Server Components cannot refresh sessions because they can't set cookies. The refresh has to happen upstream (in proxy.ts) before the Server Component renders. If your auth flow doesn't include proxy.ts or middleware on the request path, server-rendered pages will hit a stale token, fail the getClaims() check, and redirect to login mid-session. The proxy.ts authentication guide walks the full pattern.

Where should the JWT live in a Next.js app?

httpOnly cookies, set via @supabase/ssr. No exceptions worth taking.

// lib/supabase/server.ts (the @supabase/ssr server client)
import { cookies } from 'next/headers'
import { createServerClient } from '@supabase/ssr'

export async function createServerClientWithCookies() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() { return cookieStore.getAll() },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Server Components cannot set cookies. Middleware handles refreshes.
          }
        },
      },
    }
  )
}

@supabase/ssr writes session cookies with httpOnly: true, secure: true (in production), and sameSite: 'lax' by default. The httpOnly flag is the load-bearing one: it makes the cookie invisible to JavaScript, which means even if an attacker lands a <script> injection somewhere in your app, they can't read the access token from document.cookie. The XSS-to-account-takeover path closes.

Why not localStorage? Some older tutorials show supabase.auth.signIn({ ... }) and treat the resulting session as something you put in localStorage. localStorage is readable by any script on the page. An XSS that bypasses your CSP (and CSPs do get bypassed, see CVE-2026-44581 in the 3-layer injection defense pillar) immediately becomes account takeover because the JWT walks out the door in one line of script. The OWASP A07 Authentication Failures category specifically calls out localStorage token storage as the canonical anti-pattern [7].

Why not URL fragments or query parameters? They show up in server logs, in referrers when the user clicks an outbound link, and in browser history. Every "share this URL" feature becomes a session-leak feature. Just use cookies.

The pattern is so universal in 2026 that any Supabase tutorial showing localStorage storage should be treated as documenting a deprecated path, not a current one.

getSession vs getUser vs getClaims: which one to call

The three methods look interchangeable in the API surface. They have very different security properties.

getSession() reads the session from the cookie storage and returns it as-is. It does not verify the JWT signature. If an attacker can write to the cookie (XSS, a misconfigured proxy, a stolen Vercel preview URL), getSession() will return whatever they wrote. The Supabase docs since the asymmetric-signing-keys release explicitly discourage getSession() for authorization decisions [3]. It's still fine for UI state (showing a logged-in vs logged-out button) where the worst case is a misleading UI element.

getUser() sends a request to the Supabase Auth API and returns the user object fetched from the server. Cryptographic guarantee: the response only comes back if Supabase can verify the JWT and confirm the user is active. It also catches bans and global signouts (the user was deleted, banned, or had all sessions revoked) because it's hitting the live source of truth. Cost: one network round trip per call. At 50-200ms cross-region, this adds up across Server Action calls fast.

getClaims() validates the JWT signature locally against the cached JWKS public key set via the browser WebCrypto API [5]. Same cryptographic guarantee as getUser() for "this JWT is genuine and not expired" without the network round trip. Latency drops to single-digit milliseconds after the first call.

The tradeoff getClaims() makes: it doesn't check if the user has been banned, deleted, or had all sessions globally revoked since the JWT was issued. Those events invalidate the JWT on the server side, but a locally-validated check has no way to know. For sessions with 1-hour access tokens, the worst-case staleness window is 1 hour, which is acceptable for most apps. For sensitive operations (password reset, payment, account deletion), call getUser() to get the live check.

The decision tree:

  • Authorization on a public-facing API or Server Action: getClaims(). Fast, cryptographically sound.
  • Sensitive operations (payment, password reset, role change, anything destructive): getUser(). The 100ms network cost buys you the ban-check.
  • UI state in a Client Component (logged in or not): getSession() is fine; the consequence of being wrong is a flicker.
  • Anywhere you're tempted to use getSession for authorization: you want getClaims() instead.

The Supabase Auth in App Router guide covers the helper patterns that wrap these three behind a project-level getUser() (renamed for consistency) that returns the normalized claims shape.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

Asymmetric JWT signing keys: what changed in 2025-2026

The 2025 release changed Supabase Auth from HS256 (symmetric, shared secret) to ES256 (asymmetric, ECDSA on P-256) for new projects [1][3]. October 1, 2025 was the cutoff: new projects since then ship with asymmetric keys by default. Existing projects continue working on the legacy symmetric key and can migrate via the dashboard "Migrate JWT secret" button.

The architectural difference matters more than it looks.

With symmetric keys (HS256, the pre-2025 default): the same secret signs and verifies. Anyone who can verify (your server) can also forge. The JWT secret has to be heavily protected because it's the entire trust boundary.

With asymmetric keys (ES256, 2025+): the private key signs (lives only inside Supabase Auth), the public key verifies (exposed publicly at the JWKS endpoint). Anyone can verify, no one but Supabase can forge. The signing key never leaves Supabase's infrastructure, even into your application's environment.

Three downstream consequences:

Local verification via WebCrypto is now possible. Your application code can fetch the public key from the JWKS endpoint, cache it, and verify JWT signatures in-process with no further network calls. This is what makes getClaims() viable as a default authorization primitive. With symmetric keys, you'd have to ship the shared secret to every place that wanted to verify, which is exactly what makes shared secrets dangerous.

Key rotation without redeployment. Supabase can rotate the signing key on its side, push the new public key to the JWKS endpoint, and your application picks it up on the next cache refresh. With symmetric keys, rotation meant redeploying every service with the new secret.

The JWKS endpoint is public. https://<project>.supabase.co/auth/v1/jwks returns the public key set. Don't try to protect it; it's meant to be public.

In .env.local terms, the symmetric-key world used SUPABASE_JWT_SECRET. The asymmetric world doesn't need that variable on your side at all; the public key is fetched at runtime from the JWKS endpoint. If you're migrating an existing project, delete the SUPABASE_JWT_SECRET variable after migration completes; leaving it in is harmless but creates the impression you might still need it.

There's also a parallel migration happening on API keys. The legacy anon and service_role keys will work until end of 2026, but new projects ship with sb_publishable_xxx and sb_secret_xxx keys [3]. The new format is identical in role (publishable vs service-role) but uses a clearer naming convention. Either set works for now; new projects should default to the new format.

Custom JWT claims via the Custom Access Token Hook

Out of the box, the Supabase JWT carries sub, email, role, aal, plus the metadata blobs. For RBAC, multi-tenancy, plan tiers, or any project-specific authorization data, you add custom claims via the Custom Access Token Auth Hook. Supabase calls the hook before issuing each JWT; the hook can inject arbitrary claims into the payload.

CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql STABLE
AS $$
DECLARE
  claims jsonb;
  user_plan text;
BEGIN
  -- Look up the user's current plan from a profile or subscription table.
  SELECT plan_tier INTO user_plan
  FROM public.profiles
  WHERE id = (event ->> 'user_id')::uuid;

  claims := event -> 'claims';

  IF user_plan IS NOT NULL THEN
    claims := jsonb_set(claims, '{plan_tier}', to_jsonb(user_plan));
  END IF;

  event := jsonb_set(event, '{claims}', claims);
  RETURN event;
END;
$$;

-- Grant exec to the auth hook role; lock out everyone else
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook TO supabase_auth_admin;
REVOKE EXECUTE ON FUNCTION public.custom_access_token_hook FROM authenticated, anon, public;

Enable the hook in the dashboard under Authentication → Hooks (Beta).

Three practical rules for custom claims:

Keep them small. Every claim adds bytes to every request. A JWT bloats fast if you start putting whole permission lists or org rosters in the payload. Stick to short discriminators (plan_tier: 'pro', user_role: 'admin') and look up the rest server-side when needed.

Don't put sensitive data in claims. The JWT is base64-encoded, not encrypted. Anyone who can read the JWT can read the claims. Email addresses, names, and role names are fine. Internal IDs, billing details, and personally-identifying-beyond-the-basics data are not.

The hook is STABLE, not VOLATILE. Marking it VOLATILE causes performance issues during login because Supabase calls the hook on every token issuance. The function should be deterministic for the same input (user_id), and STABLE documents that contract.

For multi-tenant apps, the tenant ID + active role go into the JWT this way. The multi-tenancy and RBAC pillar walks the full pattern including the active-tenant-switching flow.

When to force a session refresh

Supabase's client library refreshes automatically when it sees an expired or near-expired access token. Most of the time that's enough. Three cases need explicit supabase.auth.refreshSession() calls:

After a tenant switch. The user picks a different active tenant from a dropdown. The new tenant ID needs to be in the JWT for RLS policies to scope correctly. Without a refresh, the next hour of requests still carries the old tenant_id claim and silently operates on the wrong scope. Call refreshSession() immediately after updating the active tenant.

After a role change. An admin promoted the user from member to admin. The user_role claim in the JWT is still member until the next refresh, so the user sees the old permissions for up to an hour. Force a refresh as part of the role-change flow.

After plan upgrade. Stripe webhook fires, the user's plan_tier updates in the database, but their existing JWT still says plan_tier: 'free'. Either force a refresh from the client when the webhook completes (via a real-time subscription), or accept the up-to-1-hour staleness for non-critical features and gate the critical paid features with a getUser() + DB lookup instead of trusting the claim.

The pattern for forced refresh:

// After updating tenant/role/plan
const { error } = await supabase.auth.refreshSession()
if (error) {
  // Refresh failed; usually means refresh token was already used
  // (rotation race condition) or revoked. Sign out and re-authenticate.
  await supabase.auth.signOut()
  router.push('/login')
}

Refresh tokens are single-use; if two tabs both try to refresh the same session simultaneously, one will succeed and one will fail with a "refresh token already used" error. The @supabase/ssr client handles this race correctly in most cases, but custom code that calls refreshSession() directly needs the error handler shown above.

Five JWT and session mistakes that ship security holes

These patterns appear in real production code and would survive a casual review.

1. Trusting getSession() for authorization. Reads from storage, doesn't verify the signature. An attacker who can write to the cookie (or who finds an XSS bypass) gets whatever they put in. The OWASP A07 Authentication Failures category lists this exact pattern as a canonical failure mode [7]. Use getClaims() (local verification) or getUser() (network truth) for any authorization decision.

2. Storing the JWT in localStorage or sessionStorage. Any script on the page can read it. The XSS-to-account-takeover escalation goes from "find an XSS" to "find an XSS, then run one line of script." httpOnly cookies via @supabase/ssr close this entirely.

3. Reading user_metadata for authorization decisions. user_metadata is editable by the user themselves through auth.updateUser({ data: { ... } }). A user can set user_metadata.is_admin = true and any policy or Server Action that reads from it grants admin access. Always put authorization-relevant data in app_metadata (writable only by service_role) or in custom claims via the auth hook (server-controlled).

4. Forgetting to handle the refresh-token single-use race. Two tabs hit the refresh endpoint within milliseconds. One gets { access_token, refresh_token }, the other gets "refresh_token_already_used". If the client treats the error as fatal and signs out, the user gets logged out on multi-tab usage. The fix is to retry once on that specific error before bailing.

5. Setting expires_in on the access token too high. Bumping the access token lifetime from 1 hour to 24 hours feels like a convenience win (fewer refreshes), but it also extends the blast radius of any leak by 24x. A stolen JWT works until exp. The 1-hour default is the right balance for most apps; only extend it after a specific operational reason (background workers that can't refresh, embedded scenarios, etc.) and document the tradeoff.

What this means for your Next.js + Supabase architecture

The JWT is the trust boundary between the browser and the database. Every other security layer (RLS policies, Server Action authorization, route guards) assumes the JWT is genuine. The three architectural commitments that hold up that assumption:

  • httpOnly cookies via @supabase/ssr for storage, never localStorage, never URL fragments.
  • getClaims() for the default authorization read, with getUser() reserved for sensitive operations that need the ban-check.
  • Refresh in proxy.ts (or middleware on pre-16 Next.js), in Server Actions, and on cookie writes, never in Server Components.

Combined with backend-only data access, Origin/Host CSRF protection, and the broader OWASP A07 authentication failures defense map, the JWT layer stops being a place where bugs ship and becomes a place where bugs are structurally hard to write. Cluster-wise, the free JWT decoder lets you inspect the claims your auth hook is actually producing, and the JWT generator lets you craft test tokens for RLS-policy and Server-Action unit tests against the patterns in the Next.js testing guide.

This is the JWT lifecycle SecureStartKit ships with by default: ES256 asymmetric keys from new-project provisioning, @supabase/ssr cookie storage, getClaims() in proxy.ts and lib/supabase/server.ts (migrated as of v1.8.4), and refresh handled at the boundary. The site you're reading runs on it.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Introducing JWT Signing Keys— supabase.com
  2. JSON Web Token (JWT)— supabase.com
  3. JWT Signing Keys— supabase.com
  4. User sessions— supabase.com
  5. auth.getClaims reference— supabase.com
  6. Server-Side Auth: Advanced guide— supabase.com
  7. OWASP Top 10:2025— owasp.org

Related Posts

May 21, 2026·Security

Supabase OAuth, Magic Links, MFA in Next.js [2026]

Secure OAuth, magic links, and MFA in Supabase + Next.js. PKCE flow, redirect URL allowlists, AAL2 step-up, and 5 implementation failure modes.

May 17, 2026·Security

Supabase Multi-Tenancy + RBAC: The Secure Pattern [2026]

Multi-tenancy and RBAC in Supabase + Next.js. Tenant scoping via JWT claims + RLS, the composite index rule, and five cross-tenant leak modes.

May 18, 2026·Security

Pre-Launch Security Audit: 12 Checks That Matter Most [2026]

Pre-launch security audit for Next.js + Supabase: 12 highest-impact checks of 30, in audit order, with triage rules. Run weeks before launch.