# 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.

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

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.

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

## The two clients

**Browser client**, for code that runs in the browser:

```ts
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`:

```ts
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:

```ts
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`:

```ts
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](/kb/pkce-flow-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](/docs/auth/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

- [Auth in Next.js (App Router)](/docs/auth/nextjs)
- [Use SupaHost with SvelteKit](/docs/getting-started/sveltekit)
- [Use SupaHost with Nuxt](/docs/getting-started/nuxt)

Related KB articles: [Session missing in fetch requests](/kb/session-missing-in-fetch), [Can auth cookies be HttpOnly?](/kb/httponly-cookies), [Migrating from auth-helpers](/kb/auth-helpers-to-ssr). The [server-side rendering guide](https://supabase.com/docs/guides/auth/server-side) in Supabase's docs covers more frameworks.
