Files
supabase__server/docs/ssr-frameworks.md
Tomás Pozo 18818e0b5d Pre-v1 API cleanup: rename allow/authType/claims, narrow authKeyName, refresh adapter docs (#48)
* feat: rename `allow` config option to `auth`

Aligns the SDK with Supabase CLI terminology — `auth: 'user'` reads
more naturally than `allow: 'user'`. The legacy `allow` key still
works (with a one-time `console.warn` per process) and will be
removed in a future major release; when both `auth` and `allow` are
provided, `auth` wins. Also exports new `AuthMode` / `AuthModeWithKey`
types alongside deprecated `Allow` / `AllowWithKey` aliases.

* feat!: rename auth mode values `'always'` → `'none'` and `'public'` → `'publishable'`

Aligns auth-mode values with Supabase CLI terminology. `'none'` reads more
directly than `'always'` for "no authentication required", and
`'publishable'` matches the `SUPABASE_PUBLISHABLE_KEY(S)` env var names.
`'secret'` and `'user'` are unchanged.

BREAKING CHANGE: the `'always'` and `'public'` mode values no longer work.
Replace `auth: 'always'` with `auth: 'none'`, `auth: 'public'` with
`auth: 'publishable'`, and `auth: 'public:<name>'` with
`auth: 'publishable:<name>'`. Runtime checks like
`ctx.authType === 'public'` must be updated to
`ctx.authType === 'publishable'`.

* feat!: rename `authType` field to `authMode` on `AuthResult` and `SupabaseContext`

Lines the field name up with its type — `authMode: AuthMode`. Reads more
naturally for both humans and AI agents working with the API.

BREAKING CHANGE: the `authType` field was renamed to `authMode` on
`AuthResult` (returned by `verifyAuth` / `verifyCredentials`) and on
`SupabaseContext` (passed to handlers). Find-and-replace
`ctx.authType` → `ctx.authMode` and `auth.authType` → `auth.authMode`
across your codebase.

* feat!: rename `claims` field to `jwtClaims` on `AuthResult` and `SupabaseContext`

Pairs naturally with `userClaims` and makes the snake_case JWT payload
distinct from the normalized identity view at a glance.

* refactor!: narrow `SupabaseContext.authKeyName` to `string | undefined`

The field used to be `string | null | undefined` (optional + explicitly
nullable), forcing consumers to handle two absence values. Collapse to a
single representation by dropping `null`: the property is simply omitted
for `'user'` and `'none'` modes, which don't match a named key.

`AuthResult.keyName` keeps its `string | null` shape — it's the
low-level type where the field is always present and `null` actively
signals "no named key for this mode."

* docs: add publishable-key example to README quick start

The auth-modes table documented the publishable mode but the quick start
only showed user, none, secret, dual, and server-to-server examples,
leaving readers without a concrete shape for publishable. Slot it
between the "no auth" and "secret" examples so the progression reads
no key → publishable (anon, key-gated) → secret (admin, key-gated).

The example clarifies the resulting client behavior — `supabase` is
anonymous, RLS still applies, and the publishable key is a client gate
rather than a user identity — which is the most common point of
confusion vs. `auth: 'secret'`.

* docs: update skill description

* docs: update ssr references accross docs and skill

* docs(adapters): add ecosystem index + community contribution guide

Adds src/adapters/README.md (index, maintenance model, contribution
checklist) and docs/adapters/h3.md. Moves docs/hono-adapter.md into
docs/adapters/. Slims the top-level README Framework Adapters section
to a canonical adapter table + brief examples. Updates CONTRIBUTING.md,
docs/getting-started.md, and the supabase-server skill to reference the
new paths.

* docs: sweep adapter and SSR docs to use renamed API

The cherry-picked docs commits were authored before the API renames in
this branch, so the new content arrived using `allow:`, `'always'`,
`'public'`, `claims`, `authType`, and `AllowWithKey`. Update the
newly-arrived files in line with the renamed API:

- docs/adapters/h3.md — `allow:` → `auth:`, `claims, authType` →
  `jwtClaims, authMode` throughout
- docs/ssr-frameworks.md — composed Next.js adapter example now uses
  `auth:` / `AuthModeWithKey` / `jwtClaims` / `authMode`
- src/adapters/README.md — adapter-test checklist mentions the four
  current modes (`'user'`, `'publishable'`, `'secret'`, `'none'`)
- CONTRIBUTING.md — same wording fix in the adapter-PR section
- skills/supabase-server/SKILL.md — top-level skill description points
  at `auth:` and the new mode values; legacy patterns folded into the
  existing migration trigger
- src/adapters/hono/middleware.ts — inline comment example uses `auth:`
  in both halves rather than mixing legacy and current option names
- README.md — collapsed Hono and H3 quick-start snippets use `auth:`

Migration prose (`README.md` callout, `docs/auth-modes.md` callout,
`docs/api-reference.md` deprecated-aliases section, `SKILL.md`
migration callouts) intentionally still references the old names; they
document the migration itself.

* docs: reframe Beta disclaimer for v1 launch + extract MIGRATION.md

The Beta callout ("APIs and documentation may change") directly
contradicts the SemVer commitment that v1 makes. For launch material
that pins to v1, the contradiction undermines the stability message
the version number is meant to carry.

Replace it with a v1.0 callout that leads with stability under SemVer
and follows with honest "active development continues" framing — new
adapters and ergonomic improvements in minor releases, breaking
changes only ever in a major bump.

Move the v0 → v1 rename map out of the README and into a dedicated
MIGRATION.md. The README quick start was buried under 20+ lines of
migration tables that only matter to upgraders, not first-time
readers — exactly the wrong tradeoff at launch. New short callout
points upgraders at MIGRATION.md.

SKILL.md gets the same Beta → v1.0 swap. The agent-operational
migration rules (lines 12-14: "always emit `auth:` in new code", "the
new mode values are `'none'` / `'publishable'`") are kept inline —
they're rules the agent applies every time it writes code, not
user-facing migration steps, so they don't belong in MIGRATION.md.

* docs: reframe v1.0 callout as "Public Beta" to match Supabase house style

The previous "Stable under SemVer; active development continues" framing
mixed two distinct axes — code stability (SemVer) and product
lifecycle stage (Public Beta / GA) — into the SemVer line. Several
Supabase docs run those independently: a release can be v1+ in SemVer
terms and still labeled Public Beta in lifecycle terms.

Lead with both signals in the headline: "v1.0 — Public Beta." Keep the
SemVer commitment ("breaking changes only ship as a major bump") so
launch copy can pin to v1, and pair it with the Public Beta lifecycle
stage so readers know the product line is still early. Same swap in
the SKILL.md mirror.
2026-05-06 12:16:14 +03:00

10 KiB

Cookie-based environments (Next.js, SvelteKit, Remix)

When you need this

In cookie-based frameworks like Next.js, Nuxt, SvelteKit, and Remix, the user's JWT lives in session cookies rather than the Authorization header. The high-level wrappers (withSupabase, createSupabaseContext) expect a standard Request with auth headers, so they don't work directly here.

The recommended pattern is to compose @supabase/server with @supabase/ssr:

  • @supabase/ssr owns the cookie session lifecycle — reads cookies, writes cookies, and handles refresh-token rotation via middleware.
  • @supabase/server adds JWT verification (verifyCredentials), an RLS-scoped server client (createContextClient), and a service-role client (createAdminClient) on top.

You hand @supabase/ssr's fresh access token to verifyCredentials, then build typed clients from the result.

How the pieces fit

  1. @supabase/ssr middleware runs on every request and refreshes the access token cookie. Without it, the cookie goes stale, verifyCredentials rejects expired tokens, and the user appears logged out — even with a valid refresh token. (Server Components can't write cookies, which is why the refresh has to happen in middleware.)
  2. @supabase/ssr createServerClient runs inside your Server Component / Route Handler, reads the (now-fresh) cookie, and exposes auth.getSession() / auth.getUser().
  3. verifyCredentials from @supabase/server/core cryptographically verifies that access token against JWKS and returns the parsed claims.
  4. createContextClient builds an RLS-scoped supabase-js client bound to the verified token.
  5. createAdminClient builds a service-role client (no token needed).

Step 1 — @supabase/ssr middleware (refresh-token rotation)

This middleware is required. It refreshes the access token cookie before any Server Component or Route Handler runs:

// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function middleware(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),
          )
        },
      },
    },
  )

  // Triggers refresh-token rotation and writes the new cookies via setAll.
  await supabase.auth.getUser()

  return supabaseResponse
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

If you skip this middleware, the cookie's access token will eventually expire and verifyCredentials will reject the request.

Step 2 — composed adapter

The adapter reads the (middleware-refreshed) cookie via @supabase/ssr, then hands the access token to @supabase/server's primitives. The return shape matches the high-level createSupabaseContext, so callers see a familiar { supabase, supabaseAdmin, userClaims, jwtClaims, authMode } bundle.

// lib/supabase/context.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import {
  verifyCredentials,
  createContextClient,
  createAdminClient,
} from '@supabase/server/core'
import type {
  AuthModeWithKey,
  SupabaseContext,
  SupabaseEnv,
} from '@supabase/server'

function resolveNextEnv(): Partial<SupabaseEnv> {
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL
  const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
  const secretKey = process.env.SUPABASE_SECRET_KEY

  return {
    url: url ?? undefined,
    publishableKeys: publishableKey ? { default: publishableKey } : {},
    secretKeys: secretKey ? { default: secretKey } : {},
  }
}

let cachedJwks: SupabaseEnv['jwks'] = null

async function getJwks(supabaseUrl: string): Promise<SupabaseEnv['jwks']> {
  if (cachedJwks) return cachedJwks
  try {
    const res = await fetch(`${supabaseUrl}/auth/v1/.well-known/jwks.json`)
    if (!res.ok) return null
    cachedJwks = await res.json()
    return cachedJwks
  } catch {
    return null
  }
}

export async function createSupabaseContext(
  options: { auth?: AuthModeWithKey | AuthModeWithKey[] } = { auth: 'user' },
): Promise<
  { data: SupabaseContext; error: null } | { data: null; error: Error }
> {
  const nextEnv = resolveNextEnv()

  if (!nextEnv.url || !nextEnv.publishableKeys?.default) {
    return {
      data: null,
      error: new Error('Missing SUPABASE_URL or SUPABASE_PUBLISHABLE_KEY'),
    }
  }

  // Read the @supabase/ssr session cookie. The middleware above has already
  // refreshed the access token, so getSession() returns a fresh JWT.
  const cookieStore = await cookies()
  const ssrClient = createServerClient(
    nextEnv.url,
    nextEnv.publishableKeys.default,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options),
            )
          } catch {
            // Server Components can't write cookies — middleware handles it.
          }
        },
      },
    },
  )

  const {
    data: { session },
  } = await ssrClient.auth.getSession()
  const token = session?.access_token ?? null

  const jwks = await getJwks(nextEnv.url)
  const env: Partial<SupabaseEnv> = { ...nextEnv, jwks }

  const { data: auth, error } = await verifyCredentials(
    { token, apikey: null },
    { auth: options.auth ?? 'user', env },
  )

  if (error) {
    return { data: null, error }
  }

  const supabase = createContextClient({
    auth: { token: auth!.token },
    env,
  })
  const supabaseAdmin = createAdminClient({ env })

  return {
    data: {
      supabase,
      supabaseAdmin,
      userClaims: auth!.userClaims,
      jwtClaims: auth!.jwtClaims,
      authMode: auth!.authMode,
    },
    error: null,
  }
}

Does this replace @supabase/ssr?

No. @supabase/ssr handles cookie-based session management for frameworks like Next.js and SvelteKit. @supabase/server handles stateless, header-based auth for Edge Functions, Workers, and other backend runtimes. As you can see in the Next.js example above, the composable primitives already work in SSR environments but require more setup. The two packages coexist and are not replacements for each other. Deeper integration with @supabase/ssr is on the roadmap.

Environment variable bridging

SSR frameworks often use their own naming conventions for environment variables. Map them to a Partial<SupabaseEnv> that the core primitives expect:

import type { SupabaseEnv } from '@supabase/server'

function resolveEnvFromFramework(): Partial<SupabaseEnv> {
  // Example: Next.js uses NEXT_PUBLIC_* for client-exposed vars
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL
  const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
  const secretKey = process.env.SUPABASE_SECRET_KEY

  return {
    url: url ?? undefined,
    publishableKeys: publishableKey ? { default: publishableKey } : {},
    secretKeys: secretKey ? { default: secretKey } : {},
    // JWKS: either set SUPABASE_JWKS env var, or fetch it (see below)
  }
}

JWKS resolution

JWT verification requires a JWKS (JSON Web Key Set). Two options:

Option 1: Set the SUPABASE_JWKS environment variable. This is auto-available on the Supabase platform and in local CLI. If set, the core primitives pick it up automatically — no extra code needed.

Option 2: Fetch from the well-known endpoint and cache. Useful when deploying to environments where SUPABASE_JWKS isn't set:

import type { SupabaseEnv } from '@supabase/server'

let cachedJwks: SupabaseEnv['jwks'] = null

async function getJwks(supabaseUrl: string): Promise<SupabaseEnv['jwks']> {
  if (cachedJwks) return cachedJwks

  try {
    const res = await fetch(`${supabaseUrl}/auth/v1/.well-known/jwks.json`)
    if (!res.ok) return null
    cachedJwks = await res.json()
    return cachedJwks
  } catch {
    return null
  }
}

The cache lives in module scope, so it persists across requests for the lifetime of the server process. For serverless environments (e.g., Vercel), the cache is per-invocation — consider using an external cache or always setting SUPABASE_JWKS.

Usage

In a Server Component

// app/page.tsx
import { createSupabaseContext } from '@/lib/supabase/context'
import { redirect } from 'next/navigation'

export default async function Home() {
  const { data: ctx, error } = await createSupabaseContext()

  if (error) {
    redirect('/auth/login')
  }

  const { data: todos } = await ctx!.supabase.from('todos').select()

  return (
    <ul>
      {todos?.map((t) => (
        <li key={t.id}>{t.title}</li>
      ))}
    </ul>
  )
}

In a Route Handler

// app/api/todos/route.ts
import { createSupabaseContext } from '@/lib/supabase/context'

export async function GET() {
  const { data: ctx, error } = await createSupabaseContext()

  if (error) {
    return Response.json({ message: error.message }, { status: 401 })
  }

  const { data } = await ctx!.supabase.from('todos').select()
  return Response.json(data)
}

With different auth modes

// Public endpoint — no auth required
const { data: ctx } = await createSupabaseContext({ auth: 'none' })

// Accept either user JWT or skip auth
const { data: ctx } = await createSupabaseContext({ auth: ['user', 'none'] })

Adapting for other frameworks

The adapter above is Next.js-specific only in how it wires @supabase/ssr's cookie adapter. To adapt for another framework, swap the cookie adapter you pass to createServerClient from @supabase/ssr — see @supabase/ssr's framework guides for the canonical patterns:

  • SvelteKit: event.cookies.getAll() / event.cookies.set(name, value, options) in +page.server.ts or +server.ts.
  • Remix: parse cookies from request.headers.get('cookie') and emit them via Set-Cookie in the response.
  • Nuxt: use useCookie / getCookie / setCookie from h3 inside server routes.

Everything else — env bridging, JWKS fetching, verifyCredentials, client creation — stays the same.