Server-side auth and SSR

Use Supabase Auth from server-rendered apps on SupaHost with @supabase/ssr: cookie-based sessions, the PKCE flow and callback route, and when to use getClaims, getUser or getSession.

In a browser-only app, supabase-js keeps the session in localStorage and attaches the access token to every request. A server-rendered app (Next.js, SvelteKit, Nuxt, Remix, Astro, Express with templates) can't read localStorage, so the session has to travel in cookies instead. The @supabase/ssr package does that: it gives you a browser client and a server client that both read and write the same cookies.

Nothing about this is different on SupaHost. Point the clients at your project URL and anon key as usual.

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

The two clients

Browser client, for code that runs in the browser:

import { createBrowserClient } from '@supabase/ssr'

// Read these from your framework's public env vars
export const supabase = createBrowserClient(
  PUBLIC_SUPABASE_URL,      // https://app-one.acme.supahost.space
  PUBLIC_SUPABASE_ANON_KEY
)

It stores the session in cookies instead of localStorage, so the server sees it on the next request.

Server client, created with createServerClient for every request. You give it two functions: getAll, which returns the request's cookies as { name, value } pairs, and setAll, which writes { name, value, options } cookies onto the response. The Express example below shows both. Don't share one server client between requests: each one holds a single user's cookies.

setAll is called when the library has new cookies to write, typically after refreshing an expired access token. Recent versions of @supabase/ssr also pass cache headers as a second argument. Apply them to the response, so a CDN never caches a page together with someone's session cookie.

An Express example

The same pattern in plain Express, using the cookie helpers that ship with @supabase/ssr:

import express from 'express'
import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'

const app = express()

function supabaseFor(req: express.Request, res: express.Response) {
  return createServerClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, {
    cookies: {
      getAll() {
        return parseCookieHeader(req.headers.cookie ?? '')
      },
      setAll(cookiesToSet, headers) {
        cookiesToSet.forEach(({ name, value, options }) =>
          res.appendHeader('Set-Cookie', serializeCookieHeader(name, value, options))
        )
        Object.entries(headers ?? {}).forEach(([key, value]) => res.setHeader(key, value))
      },
    },
  })
}

app.get('/invoices', async (req, res) => {
  const supabase = supabaseFor(req, res)
  const { data } = await supabase.auth.getClaims()
  if (!data?.claims) return res.redirect('/login')

  const { data: invoices } = await supabase.from('invoices').select('id, total, status')
  res.render('invoices', { invoices })
})

Queries made with the server client run as the signed-in user, so your RLS policies apply exactly as they do in the browser.

The PKCE flow and the callback route

Server-side clients use the PKCE flow. Instead of returning tokens in the URL fragment (which a server never sees), Auth redirects back with a short-lived ?code= parameter, and your server swaps it for a session. The client stored a matching "code verifier" cookie when the flow started, so the swap only works in the same browser.

That applies to OAuth sign-ins and to email links: sign-up confirmation, magic links, password resets and invites. Give all of them one callback route:

app.get('/auth/callback', async (req, res) => {
  const code = String(req.query.code ?? '')
  const next = String(req.query.next ?? '/')
  const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/'

  if (code) {
    const supabase = supabaseFor(req, res)
    const { error } = await supabase.auth.exchangeCodeForSession(code)
    if (!error) return res.redirect(safeNext)
  }
  res.redirect('/login?error=callback')
})

Then:

  1. Add the callback to Redirect URLs in Dashboard → your project → Auth tab → Auth card, for example https://invoices.example.com/auth/callback** (the ** allows the ?next= part).
  2. Pass it as redirectTo or emailRedirectTo:
const { data } = await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: { redirectTo: 'https://invoices.example.com/auth/callback?next=/invoices' },
})
res.redirect(data.url!)

The safeNext check stops an attacker from using your callback as an open redirect to another site.

If a user opens an email link in a different browser from the one they signed up in, the code verifier is missing and the exchange fails. Offer the one-time code from the same email as a fallback (verifyOtp), which works anywhere. See PKCE errors.

getClaims, getUser or getSession

The session cookie is set by the browser, so a server can't trust its contents without checking. The three methods differ in what they check:

Method What it does Use it for
getClaims() Verifies the access token and returns its claims; refreshes it first if it's about to expire Protecting pages, routes and server actions
getUser() Asks Auth for the current user record When you need fresh user data, or must notice a revoked session
getSession() Reads the session from the cookie without verifying it Getting the raw access token to forward; never for authorization

SupaHost projects sign tokens with HS256 (a shared secret) rather than asymmetric keys. With HS256, getClaims() can't verify the signature locally, so it asks the Auth server, which costs about the same as getUser(). Both are safe. Just don't skip them in favour of getSession() on the server.

Refresh in one place

Access tokens expire (after an hour by default; see Sessions). The server client refreshes them when needed and writes new cookies through setAll. Some frameworks can't write cookies while rendering a page, so the usual pattern is a middleware or hook that runs on every request, calls getClaims() and writes any refreshed cookies onto the response before the page renders.

Refresh tokens are single use. Two places refreshing the same token at once (middleware and a page, or a server and the browser) can look like reuse, and Auth then ends the session. If users are signed out at random, look for duplicate refreshes first.

Framework guides

Related KB articles: Session missing in fetch requests, Can auth cookies be HttpOnly?, Migrating from auth-helpers. The server-side rendering guide in Supabase's docs covers more frameworks.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us