Supabase

Supabase Authentication: Sessions, Refresh Tokens, OAuth, and Secure Auth Flows

Understand Supabase authentication, sessions, refresh tokens, PKCE OAuth, and secure authorization patterns for production Next.js applications.

Aarav Sharma

Aarav Sharma

September 12, 2026
Share
Supabase authentication session and refresh token flow

Supabase authentication feels simple until the first production bug. A user gets signed out at random. A protected page renders for a visitor who is not logged in. An OAuth callback dies with a code verifier error that only shows up on mobile Safari. Nearly all of these problems trace back to the same thing: how the session and its refresh token actually travel between the browser, your server, and the Auth service. This guide covers the session model, refresh token rotation and reuse detection, the PKCE OAuth flow in a Next.js App Router app, and which auth checks are genuinely safe to trust on the server.

Quick Answer

A Supabase session is a pair: a short-lived access token (a JWT, one hour by default) and a refresh token that never expires but can only be exchanged once. Client libraries refresh the pair automatically in the background, and each exchange returns a brand new pair. For server-rendered apps, store that pair in cookies using the @supabase/ssr package, sign users in through the PKCE OAuth flow, and authorize requests with getClaims() rather than getSession(), because only getClaims() verifies the JWT signature.

What Is Supabase Auth?

Supabase Auth is the authentication service bundled with every Supabase project. It issues and validates JSON Web Tokens, stores users in an auth.users table inside your own Postgres database, and connects those tokens to Row Level Security policies so the database itself enforces who can read or write each row.

  • Handles email/password, magic links, OTP, passkeys, anonymous sign-in, and roughly twenty OAuth providers.
  • Issues access tokens whose claims (sub, role, session_id) are readable by Postgres RLS policies.
  • Suits teams that want managed auth without running a separate identity server alongside their database.
  • Requires no separate user table sync, since users already live in the same database as your application data.

The Problem: Auth Bugs Show Up in Production, Not in Development

Sign-in almost always works on the first try. What breaks later is everything around it. A single-page app keeps tokens in localStorage, so the server has no idea who is calling. A Next.js Server Component reads a cookie and trusts it, unaware that cookies are attacker-controlled input. Two browser tabs race to spend the same single-use refresh token, Supabase treats the second attempt as a stolen token, and the whole session gets revoked.

These failures share one root cause: the session is not one value living in one place. It is two values that must stay synchronized across the browser, the edge, and the server, and the rules for refreshing them are stricter than most developers expect.

The Solution: Cookie-Based Sessions With Verified Claims

The current recommended architecture puts the token pair in cookies that both the browser and the server can read, refreshes them in a single place at the edge, and verifies the JWT signature on every authorization check instead of trusting whatever the cookie contains.

Browser (createBrowserClient)
        |
        |  cookies: sb-<ref>-auth-token
        v
Next.js Proxy / Middleware  -->  getClaims() refreshes if expired
        |                        and rewrites both cookie jars
        v
Server Components / Route Handlers (createServerClient)
        |
        |  Authorization: Bearer <access token JWT>
        v
Supabase Auth  ---->  PostgreSQL + Row Level Security
PKCE OAuth flow between a Next.js app and Supabase Auth

Three pieces make this work:

  • @supabase/ssr replaces localStorage with cookie storage, so the same session is visible to server code.
  • A proxy (called middleware before Next.js 16) refreshes expiring tokens once per request, because Server Components cannot write cookies.
  • getClaims() validates the token signature against the project public keys, which makes it safe for route protection.

How Supabase Sessions and Refresh Tokens Actually Work

The token pair

A session is created at sign-in and stored in the auth.sessions table. Your app receives an access token and a refresh token. The access token is a JWT that typically lives between five minutes and one hour, with one hour as the default. The refresh token never expires on its own, but it is single-use: exchanging it returns a fresh access token and a fresh refresh token. Every access token also carries a session_id claim, a UUID that maps back to the primary key of the row in auth.sessions.

Supabase's guide to user sessions documents the full lifecycle, including how reuse detection decides a token was stolen.

Supabase advises against dropping the JWT expiry below five minutes. Very short expiry increases load on the Auth server, and it collides with clock skew, since user devices can be off by minutes or more.

Refresh token rotation and reuse detection

Because a refresh token is single-use, presenting one twice normally means it leaked. Supabase treats that as a compromise: the entire session is terminated and every refresh token attached to it is revoked. Two exceptions keep this from firing on ordinary traffic:

  • A reuse interval, ten seconds by default, during which the same token may be exchanged more than once. This covers server-side rendering, where server and client legitimately use the same token moments apart.
  • A parent-token exception: if the parent of the currently active refresh token is presented, the active token is returned instead of revoking anything. This rescues clients that spent a token but lost the response to a network failure.

Reuse detection protects against a token leaked through logs, request bodies, or URL parameters. It does not protect against a session stolen directly from a user device.

Session lifetime controls

On Pro plans and above, three settings limit how long a session survives: time-boxed sessions that end after a fixed duration, an inactivity timeout for sessions that go unrefreshed, and single session per user, which keeps only the most recent sign-in. None of these are enforced proactively. The check runs at the next refresh, so real session duration is the configured timeout plus the remaining JWT lifetime.

Session timeout and access token expiry settings in the Supabase dashboard
Refresh token reuse detection and reuse interval settings in Supabase

Prerequisites

  • Node.js 18 or later
  • A Next.js 14+ project using the App Router
  • A Supabase project and its URL plus publishable key
  • Working knowledge of TypeScript and HTTP cookies

Step-by-Step Implementation

Step 1: Install the client packages

npm install @supabase/supabase-js @supabase/ssr

Use @supabase/ssr when the session lives in cookies. If auth instead arrives per request as an Authorization header, @supabase/server is the better fit, and plain supabase-js is right when you handle tokens yourself.

Step 2: Configure environment variables

# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...

Step 3: Create the browser and server clients

You need two clients because browser and server code read cookies differently; Supabase's guide on creating a client for SSR covers both, plus the equivalents for SvelteKit, Astro, and Remix.

// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
 
export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  )
}
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
 
export async function createClient() {
  const cookieStore = await cookies()
 
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Server Components cannot write cookies.
            // The proxy handles the refresh instead, so this is safe to swallow.
          }
        },
      },
    }
  )
}

Step 4: Refresh the session at the edge

Server Components cannot set cookies, so a refreshed token would be lost. A proxy solves this by refreshing once per request and writing the result into both the request (for downstream Server Components) and the response (for the browser). On Next.js 15 and earlier this file is middleware.ts exporting a middleware function; the logic is identical.

// lib/supabase/proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
 
export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request })
 
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )
 
  // Verifies the JWT and refreshes the pair when the access token has expired.
  const { data } = await supabase.auth.getClaims()
 
  if (!data?.claims && request.nextUrl.pathname.startsWith('/dashboard')) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }
 
  return supabaseResponse
}
// proxy.ts  (middleware.ts on Next.js 15 and earlier)
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/proxy'
 
export async function proxy(request: NextRequest) {
  return await updateSession(request)
}
 
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

One rule matters more than the rest here: if you build a new NextResponse anywhere in this function, copy the cookies from supabaseResponse onto it. Dropping them desynchronizes browser and server, and the symptom is users being logged out seemingly at random.

Step 5: Start the OAuth flow

Calling signInWithOAuth in the browser redirects the user to the provider. Pass a redirectTo pointing at your callback route, and add that URL to the redirect allow list in the dashboard.

Provider panel
Enabling the GitHub OAuth provider in the Supabase dashboard
'use client'
import { createClient } from '@/lib/supabase/client'
 
export function GitHubButton() {
  const supabase = createClient()
 
  const signIn = async () => {
    await supabase.auth.signInWithOAuth({
      provider: 'github',
      options: {
        redirectTo:`${location.origin}/auth/callbacknext=/dashboard`,
      },
    })
  }
 
  return <button onClick={signIn}>Continue with GitHub</button>
}

Step 6: Exchange the auth code for a session

Under PKCE, the provider sends the user back with an auth code in the URL rather than tokens. That code is valid for five minutes and can be exchanged exactly once. exchangeCodeForSession trades it for the token pair and writes the session to cookies.

// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
 
export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
 
  let next = searchParams.get('next') ?? '/'
  if (!next.startsWith('/')) next = '/'   // block open redirects
 
  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)
 
    if (!error) {
      const forwardedHost = request.headers.get('x-forwarded-host')
      const isLocalEnv = process.env.NODE_ENV === 'development'
 
      if (isLocalEnv) return NextResponse.redirect(`${origin}${next}`)
      if (forwardedHost) return NextResponse.redirect(`https://${forwardedHost}${next}`)
      return NextResponse.redirect(`${origin}${next}`)
    }
  }
 
  return NextResponse.redirect(`${origin}/auth/auth-code-error`)
}

PKCE also applies to magic links, sign-up confirmation, and password recovery, so the same callback pattern covers those flows.

See the PKCE flow reference for how the code verifier is generated, stored, and matched during the exchange.

Step 7: Protect pages with verified claims

Supabase exposes three read methods, and picking the wrong one is the most common security mistake in Supabase apps.

MethodWhat it doesUse it for
getClaims()Verifies the JWT signature locally via WebCrypto and a cached JWKS endpoint when the project uses asymmetric keysProtecting pages and data
getUser()Network call to the Auth server for the current user recordFresh profile data after an update
getSession()Reads the session straight from storage without revalidating itGetting the raw access token to forward
// app/dashboard/page.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
 
export default async function Dashboard() {
  const supabase = await createClient()
  const { data } = await supabase.auth.getClaims()
 
  if (!data?.claims) redirect('/login')
 
  // Rows are still filtered by RLS using auth.uid()
  const { data: projects } = await supabase.from('projects').select('*')
 
  return <ProjectList projects={projects ?? []} userId={data.claims.sub} />
}

Never gate server-side access on getSession(). It returns whatever is in the cookie, and cookies can be forged. getClaims() checks the signature against the project published public keys on every call.

Step 8: Verify the implementation

  1. Sign in and confirm an sb-<project-ref>-auth-token cookie appears in DevTools.
  2. Decode the access token at jwt.io and check the sub, role, and session_id claims.
  3. Set JWT expiry to five minutes in the dashboard, wait it out, then reload and watch the proxy issue a new pair.
  4. Confirm the row in auth.sessions matches the session_id claim.
  5. Call signOut() and verify the protected route now redirects.

Common Problems and Errors

Users get signed out at random

Almost always refresh token reuse detection firing on legitimate traffic. Usual causes: a proxy that builds a new NextResponse without copying the auth cookies, two Supabase client instances refreshing in parallel, or Next.js route prefetching sending a server request before the browser has stored the new tokens. Fix the cookie write-back path first, and keep exactly one client instance per environment.

The auth code exchange fails

The code verifier lives in browser storage created at sign-in time. If the callback runs in a different browser, a different device, or after storage was cleared, the exchange fails. Confirm the redirectTo value is on the redirect allow list, and complete the flow in the browser that started it. When several PKCE flows may be in flight at once, pass options.flowId to exchangeCodeForSession so the right verifier is selected.

One user sees another user account

A caching bug, not an auth bug. Refreshed tokens are returned via Set-Cookie. If ISR or a CDN caches that response and serves it to someone else, the second visitor is signed in as the first. Never cache authenticated responses, and mark routes that touch cookies as dynamic.

OAuth redirects to localhost in production

The Site URL in the dashboard is still the development value, and Supabase falls back to it whenever redirectTo is missing or not on the allow list. Set the production Site URL and list every environment origin explicitly.

redirect allow list configuration in Supabase

Rotating JWT signing keys breaks the backend

Any service verifying tokens against the legacy JWT secret with a library such as jose will reject tokens signed by a new key. Edge Functions with the Verify JWT setting enabled break the same way. Migrate that code to getClaims() or to JWKS-based verification before rotating.

Best Practices

  • Authorize with getClaims() on the server; treat getSession() as untrusted input.
  • Enable Row Level Security on every table so a leaked token still cannot read other users data.
  • Keep the secret key server-side only, and never behind a NEXT_PUBLIC_ prefix.
  • Keep the JWT expiry at one hour unless you have a specific reason, and never below five minutes.
  • Maintain a tight redirect allow list, and reject any next parameter that is not a relative path.
  • Move off the legacy JWT secret to asymmetric signing keys, which rotate without signing anyone out.
  • Handle the error branch of every auth call; silent failures surface later as phantom logouts.

Security and Performance Considerations

Asymmetric signing keys

New projects default to asymmetric JWT signing keys (ES256 recommended, RS256 supported). The public key is published at /auth/v1/.well-known/jwks.json, so your app verifies tokens locally instead of calling the Auth server on every request. That removes Auth from the hot path and cuts latency. Rotation causes no downtime and signs nobody out, because previously issued tokens stay valid until they expire.

The JWT signing keys guide covers algorithm choice, the rotation states, and what to check before revoking the legacy secret.

The tradeoff is caching. The JWKS endpoint is cached about ten minutes at the edge and up to another ten minutes in the client library. Supabase products themselves do not rely on that cache, so revocation is immediate for RLS, Storage, and Realtime, but your own backend may briefly keep trusting a revoked key.

Provider tokens

Supabase does not store the provider access token or provider refresh token. If you need to call the provider API later, read provider_token from the session returned by exchangeCodeForSession and store it yourself, encrypted. Google withholds a refresh token unless you request access_type: offline and prompt: consent.

Sign-out semantics

Signing out deletes the affected rows from auth.sessions, but an already-issued access token stays cryptographically valid until it expires. For high-risk operations, check that the session_id claim still matches a row in auth.sessions before proceeding.

Supabase Auth vs Firebase Authentication

AspectSupabase AuthFirebase Authentication
User storeauth.users in your own PostgresManaged, external to your database
Token modelAccess JWT plus single-use refresh tokenID token plus long-lived refresh token
AuthorizationPostgres Row Level Security on JWT claimsSecurity Rules on Firestore or RTDB
Token verificationJWKS, verified locallyAdmin SDK or public certificates
Self-hostingYes, the stack is open sourceNo

When Should You Use Supabase Auth?

It is a strong fit when:

  • Your data already lives in Postgres and you want authorization enforced in the database.
  • You need social login and email flows without operating an identity server.
  • You are building server-rendered React and want cookie sessions that work on both sides.
  • Self-hosting has to remain an option later.

Consider alternatives when:

  • You need enterprise features such as SCIM provisioning or a deep directory integration.
  • An existing identity provider is already the source of truth, in which case third-party auth integration fits better.
  • Your authorization model is far more complex than what claims plus RLS can express.

Frequently Asked Questions

Do Supabase refresh tokens expire?

Not on a timer. A refresh token stays valid indefinitely but can only be exchanged once. Sessions end when the user signs out, when a configured timeout or time-box is reached, or when reuse detection revokes the session.

How long does a Supabase session last?

By default it lasts until the user signs out. The access token expires after an hour and is refreshed silently. Pro plans and above can add a time-box, an inactivity timeout, or a single-session-per-user rule.

Is getSession() safe to use on the server?

No, not for authorization. It reads the session from cookies without revalidating it, and cookies can be spoofed. Use getClaims() to verify identity; getSession() is fine when you only need the raw token to forward to another service.

Can I store Supabase tokens in HTTP-only cookies?

Only for apps that are entirely server-rendered. Any client-side JavaScript that needs to read or refresh the session cannot do so from an HTTP-only cookie, which is why the SSR package uses readable cookies plus signature verification instead.

What is the difference between implicit flow and PKCE flow?

Implicit flow returns tokens directly in the URL fragment, which only the browser can read. PKCE returns a short-lived auth code that your server exchanges for tokens. PKCE is the right choice for server-side rendering and mobile apps.

Is Supabase Auth production ready?

Yes, when configured correctly: RLS on every table, asymmetric signing keys, verified claims on the server, a tight redirect allow list, and no caching of authenticated responses.

Conclusion

Supabase authentication problems are rarely about signing users in. They come from the session lifecycle: a token pair where the access token is short-lived and the refresh token can be spent exactly once, moving between browser and server through cookies. Get three things right and most production bugs disappear. Store the session in cookies with @supabase/ssr. Refresh it in one place, writing cookies back to both the request and the response. Authorize with getClaims() so every check verifies a signature rather than trusting a cookie. Layer RLS underneath, and the database enforces your rules even if a token leaks.

Need Help With Your Authentication Layer?

If you are building or auditing authentication on Supabase, Next.js, or React Native, our team can help you design the session architecture, harden the OAuth flow, and get it into production. Get in touch to talk through your project.

Explore our Supabase development services or contact us to talk through your project.

Aarav Sharma

Aarav Sharma

Lead Software Engineer

Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.

Related articles

Let's Collaborate

Tell us about your project and we'll come back with a plan, a timeline, and a quote.

Project Type

Budget

Task Message

Your Contacts