diff --git a/CHANGELOG.md b/CHANGELOG.md index 55734b1..f697a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,48 +2,43 @@ ## [0.2.0](https://github.com/supabase/server/compare/server-v0.1.4...server-v0.2.0) (2026-04-24) - ### ⚠ BREAKING CHANGES -* when multiple auth modes are allowed, a present-but-invalid JWT is now rejected with InvalidCredentialsError instead of falling through to the next mode. Clients that previously relied on silent fallthrough (e.g., stale token + valid apikey) must now either omit the Authorization header or refresh the token. +- when multiple auth modes are allowed, a present-but-invalid JWT is now rejected with InvalidCredentialsError instead of falling through to the next mode. Clients that previously relied on silent fallthrough (e.g., stale token + valid apikey) must now either omit the Authorization header or refresh the token. ### Features -* add H3 adapter ([#36](https://github.com/supabase/server/issues/36)) ([4310142](https://github.com/supabase/server/commit/43101427e64c01b986376ca5d94c5e008d0adcdf)) - +- add H3 adapter ([#36](https://github.com/supabase/server/issues/36)) ([4310142](https://github.com/supabase/server/commit/43101427e64c01b986376ca5d94c5e008d0adcdf)) ### Bug Fixes -* reject invalid JWTs immediately instead of falling through to next auth mode ([#35](https://github.com/supabase/server/issues/35)) ([0251690](https://github.com/supabase/server/commit/0251690a7f57eb3e2d72074348d8a96f5fb55231)) +- reject invalid JWTs immediately instead of falling through to next auth mode ([#35](https://github.com/supabase/server/issues/35)) ([0251690](https://github.com/supabase/server/commit/0251690a7f57eb3e2d72074348d8a96f5fb55231)) ## [0.1.4](https://github.com/supabase/server/compare/server-v0.1.3...server-v0.1.4) (2026-04-01) - ### Features -* add `supabaseOptions` and refactor client creation to options objects ([#19](https://github.com/supabase/server/issues/19)) ([5a10099](https://github.com/supabase/server/commit/5a100995a1b6254f92768c82c74b1c754c29b3b2)) -* exposing `keyName` to `SupabaseContext` ([#22](https://github.com/supabase/server/issues/22)) ([7f1b1a7](https://github.com/supabase/server/commit/7f1b1a75cc98d08a63275131481e5df825c10afb)) -* implement server-side DX primitives, wrappers, and adapters ([#6](https://github.com/supabase/server/issues/6)) ([d206e5c](https://github.com/supabase/server/commit/d206e5cdb102bf96e0c501b72e7f161cbf9fba0c)) -* passing down Database generic type to `createClient` ([#16](https://github.com/supabase/server/issues/16)) ([4053f6d](https://github.com/supabase/server/commit/4053f6d8db89201a239190a025b08cf19083acb4)) -* set initial release version ([8352bda](https://github.com/supabase/server/commit/8352bda35c5967a6692f0a21744d30793e10709a)) -* standardize error response ([#18](https://github.com/supabase/server/issues/18)) ([a7ddb74](https://github.com/supabase/server/commit/a7ddb74bfbbe4565d461be7df7f01e64854f6c06)) - +- add `supabaseOptions` and refactor client creation to options objects ([#19](https://github.com/supabase/server/issues/19)) ([5a10099](https://github.com/supabase/server/commit/5a100995a1b6254f92768c82c74b1c754c29b3b2)) +- exposing `keyName` to `SupabaseContext` ([#22](https://github.com/supabase/server/issues/22)) ([7f1b1a7](https://github.com/supabase/server/commit/7f1b1a75cc98d08a63275131481e5df825c10afb)) +- implement server-side DX primitives, wrappers, and adapters ([#6](https://github.com/supabase/server/issues/6)) ([d206e5c](https://github.com/supabase/server/commit/d206e5cdb102bf96e0c501b72e7f161cbf9fba0c)) +- passing down Database generic type to `createClient` ([#16](https://github.com/supabase/server/issues/16)) ([4053f6d](https://github.com/supabase/server/commit/4053f6d8db89201a239190a025b08cf19083acb4)) +- set initial release version ([8352bda](https://github.com/supabase/server/commit/8352bda35c5967a6692f0a21744d30793e10709a)) +- standardize error response ([#18](https://github.com/supabase/server/issues/18)) ([a7ddb74](https://github.com/supabase/server/commit/a7ddb74bfbbe4565d461be7df7f01e64854f6c06)) ### Bug Fixes -* key name resolution for client creation ([#9](https://github.com/supabase/server/issues/9)) ([e17bd4e](https://github.com/supabase/server/commit/e17bd4ecb1c46d0dc1468f363c884090d78ae86a)) -* move SKILL.md into skills/ subdirectory to align with agentskills spec ([#24](https://github.com/supabase/server/issues/24)) ([10c8780](https://github.com/supabase/server/commit/10c8780cc21de3bb860d2ec8bf5589f69d4ea447)) -* release action ([#29](https://github.com/supabase/server/issues/29)) ([91580d1](https://github.com/supabase/server/commit/91580d11fd1217a22da1150757114ee980d6157b)) -* remove provenance until repo is public ([2ebbc71](https://github.com/supabase/server/commit/2ebbc71e214c4bbae62c6af203a039801b5e3d4d)) -* removing `core` lib exports from root index ([#17](https://github.com/supabase/server/issues/17)) ([5e53e3c](https://github.com/supabase/server/commit/5e53e3c14fcc7c198f1c0bbec9089b4aedd91473)) -* support bare array format for SUPABASE_JWKS ([#8](https://github.com/supabase/server/issues/8)) ([6bd2e4d](https://github.com/supabase/server/commit/6bd2e4dfc1b60ce4cc8a1b59435b87797e1cb017)) +- key name resolution for client creation ([#9](https://github.com/supabase/server/issues/9)) ([e17bd4e](https://github.com/supabase/server/commit/e17bd4ecb1c46d0dc1468f363c884090d78ae86a)) +- move SKILL.md into skills/ subdirectory to align with agentskills spec ([#24](https://github.com/supabase/server/issues/24)) ([10c8780](https://github.com/supabase/server/commit/10c8780cc21de3bb860d2ec8bf5589f69d4ea447)) +- release action ([#29](https://github.com/supabase/server/issues/29)) ([91580d1](https://github.com/supabase/server/commit/91580d11fd1217a22da1150757114ee980d6157b)) +- remove provenance until repo is public ([2ebbc71](https://github.com/supabase/server/commit/2ebbc71e214c4bbae62c6af203a039801b5e3d4d)) +- removing `core` lib exports from root index ([#17](https://github.com/supabase/server/issues/17)) ([5e53e3c](https://github.com/supabase/server/commit/5e53e3c14fcc7c198f1c0bbec9089b4aedd91473)) +- support bare array format for SUPABASE_JWKS ([#8](https://github.com/supabase/server/issues/8)) ([6bd2e4d](https://github.com/supabase/server/commit/6bd2e4dfc1b60ce4cc8a1b59435b87797e1cb017)) ## [0.1.3](https://github.com/supabase/server/compare/server-v0.1.2...server-v0.1.3) (2026-04-01) - ### Bug Fixes -* move SKILL.md into skills/ subdirectory to align with agentskills spec ([#24](https://github.com/supabase/server/issues/24)) ([10c8780](https://github.com/supabase/server/commit/10c8780cc21de3bb860d2ec8bf5589f69d4ea447)) +- move SKILL.md into skills/ subdirectory to align with agentskills spec ([#24](https://github.com/supabase/server/issues/24)) ([10c8780](https://github.com/supabase/server/commit/10c8780cc21de3bb860d2ec8bf5589f69d4ea447)) ## [0.1.2](https://github.com/supabase/server/compare/server-v0.1.1...server-v0.1.2) (2026-04-01) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe87fa8..b0ed005 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,7 @@ Thank you for your interest in contributing to `@supabase/server`! This document - [Testing](#testing) - [Code Style](#code-style) - [Submitting Changes](#submitting-changes) +- [Contributing a framework adapter](#contributing-a-framework-adapter) - [Release Process](#release-process) ## Getting Started @@ -150,6 +151,12 @@ BREAKING CHANGE: auth configuration now uses a discriminated union - Rebase on `main` if needed to resolve conflicts - Be responsive to review feedback +## Contributing a framework adapter + +Framework adapters (Hono, H3, …) are community-maintained and live in this repo under `src/adapters/`. They have **additional requirements** on top of the general PR guidelines above — tests covering every auth mode, no new runtime deps beyond a peer-dep, matching the existing adapter shape, and updating both adapter tables (in `README.md` and `src/adapters/README.md`). + +See [`src/adapters/README.md`](src/adapters/README.md) for the full checklist before opening an adapter PR. + ## Release Process This project uses [release-please](https://github.com/googleapis/release-please) for automated releases. You don't need to manually manage versions or changelogs. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..b2c0a13 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,47 @@ +# Migration + +## v0.x → v1.0 + +v1.0 ships a coordinated set of API renames adopted as part of v1 prep. They make the public surface read more naturally and align with Supabase CLI and env-var terminology. Once v2 lands, the deprecated names below will be removed. + +### Renames + +| Before | After | Notes | +| ---------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `withSupabase({ allow: ... })` | `withSupabase({ auth: ... })` | **Soft-deprecated.** `allow` still works in v1 and emits a one-time `console.warn` per process; `auth` wins when both are present. Removed in v2. | +| `auth: 'always'` | `auth: 'none'` | Reads more directly as "no authentication required". | +| `auth: 'public'` / `'public:'` | `auth: 'publishable'` / `'publishable:'` | Matches `SUPABASE_PUBLISHABLE_KEY(S)` and the `sb_publishable_...` key prefix. | +| `ctx.authType` / `auth.authType` | `ctx.authMode` / `auth.authMode` | Lines the field up with its `AuthMode` type. | +| `ctx.claims` / `auth.claims` | `ctx.jwtClaims` / `auth.jwtClaims` | Pairs naturally with `userClaims`; distinguishes the snake_case JWT payload from the normalized identity view. | +| `SupabaseContext.authKeyName?: string \| null` | `SupabaseContext.authKeyName?: string` | Single absence representation. The property is omitted for `'user'` / `'none'` modes that don't match a named key. `AuthResult.keyName` deliberately keeps `string \| null` (low-level type where the field is always present). | +| `Allow` / `AllowWithKey` (types) | `AuthMode` / `AuthModeWithKey` | **Soft-deprecated.** Old aliases still resolve to the new types; removed in v2 alongside the `allow` option. | + +### Migration cheat sheet + +Most of the migration is a find-and-replace at the call site: + +| Pattern | Replace with | +| ------------------------------------------------------------- | ------------------------------------------------------------------- | +| `allow:` | `auth:` (or leave it for now and silence the warning later) | +| `auth: 'always'` | `auth: 'none'` | +| `auth: 'public'` / `'public:'` | `auth: 'publishable'` / `'publishable:'` | +| `ctx.authType` / `auth.authType` | `ctx.authMode` / `auth.authMode` | +| `ctx.claims` / `auth.claims` | `ctx.jwtClaims` / `auth.jwtClaims` | +| `ctx.authKeyName === null` | `ctx.authKeyName === undefined` (or just `!ctx.authKeyName`) | +| `import type { Allow, AllowWithKey } from '@supabase/server'` | `import type { AuthMode, AuthModeWithKey } from '@supabase/server'` | + +### Why these names? + +- **`auth` over `allow`** — matches Supabase CLI terminology; `auth: 'user'` reads more naturally as "this endpoint authenticates a user." +- **`'none'` over `'always'`** — `'none'` reads more directly as "no authentication required" than `'always'` did as "always allow." +- **`'publishable'` over `'public'`** — matches the env var names `SUPABASE_PUBLISHABLE_KEY(S)` and the `sb_publishable_...` key prefix used everywhere else in Supabase. +- **`authMode` over `authType`** — lines up the field name with its TypeScript type (`authMode: AuthMode`). +- **`jwtClaims` over `claims`** — reading `userClaims` and `jwtClaims` next to each other makes it obvious which is the normalized identity view vs. the raw JWT payload. +- **`authKeyName?: string` over `string | null`** — single absence representation; consumers don't have to handle both `null` and `undefined`. + +### Compatibility timeline + +- **v1.x** — deprecated `allow:` option and `Allow` / `AllowWithKey` aliases continue to work; one-time `console.warn` on first use of `allow:`. +- **v2.0** — deprecated names will be removed. + +The renamed mode values (`'always'` / `'public'` → `'none'` / `'publishable'`) and the renamed fields (`authType` → `authMode`, `claims` → `jwtClaims`) are **already removed** in v1.0 — their old forms no longer work at runtime or in TypeScript. diff --git a/README.md b/README.md index 14b25ac..f64f677 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ [![pkg.pr.new](https://pkg.pr.new/badge/supabase/server)](https://pkg.pr.new/~/supabase/server) [![Docs](https://img.shields.io/badge/docs-supabase.github.io-3ECF8E?logo=readthedocs&logoColor=white)](https://supabase.github.io/server/) -> **Beta:** This package is under active development. APIs and documentation may change. If you find a bug or have a feature request, please [open an issue](https://github.com/supabase/server/issues) or [submit a PR](https://github.com/supabase/server/blob/main/CONTRIBUTING.md). +> **v1.0 — Public Beta.** First stable release under SemVer: breaking changes only ship as a major bump. The package is still early — expect new adapters, ergonomic improvements, and features to land frequently in minor releases. Found a rough edge? [Open an issue](https://github.com/supabase/server/issues) or [submit a PR](https://github.com/supabase/server/blob/main/CONTRIBUTING.md). + +> **Coming from a `0.x` release?** See [MIGRATION.md](MIGRATION.md) for the v0 → v1 rename map (`allow` → `auth`, `'public'` → `'publishable'`, `authType` → `authMode`, `claims` → `jwtClaims`, …). `@supabase/server` gives you batteries included access to the [supabase-js SDK](https://github.com/supabase/supabase-js), including client @@ -16,7 +18,7 @@ Edge Functions and APIs. import { withSupabase } from '@supabase/server' export default { - fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => { + fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { // RLS-scoped — this user only sees their own favorites const { data: myGames } = await ctx.supabase.from('favorite_games').select() return Response.json(myGames) @@ -49,20 +51,20 @@ npx skills add supabase/server ## Quick Start -Imagine you're building an app where users track their favorite games. They sign in and manage their own list. An admin dashboard curates featured titles. A cron job refreshes the "popular this week" rankings. Here's how each piece looks: +Imagine you're building an app where users track their favorite games. They sign in and manage their own list. Pre-login screens browse the public catalog. An admin dashboard curates featured titles. A cron job refreshes the "popular this week" rankings. Here's how each piece looks: ### Authenticated endpoint ```ts // A signed-in user fetches their favorite games. export default { - fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => { - const { supabase, supabaseAdmin, userClaims, claims, authType } = ctx + fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { + const { supabase, supabaseAdmin, userClaims, jwtClaims, authMode } = ctx // supabase — RLS-scoped to the authenticated user // supabaseAdmin — bypasses RLS (service role) // userClaims — user identity from JWT (id, email, role) - // claims — full JWT claims - // authType — which auth mode matched + // jwtClaims — full JWT claims + // authMode — which auth mode matched // RLS-scoped — this user only sees their own favorites const { data: myGames } = await supabase.from('favorite_games').select() @@ -75,21 +77,51 @@ export default { ```ts // The frontend hits this before showing the login screen. -// allow: 'always' means no credentials required. +// auth: 'none' means no credentials required. export default { - fetch: withSupabase({ allow: 'always' }, async (_req, _ctx) => { + fetch: withSupabase({ auth: 'none' }, async (_req, _ctx) => { return Response.json({ status: 'ok' }) }), } ``` +### Publishable-key endpoint + +```ts +// The mobile app browses the game catalog before the user signs in. +// auth: 'publishable' validates the apikey header against a publishable key — +// gating the endpoint to your own clients while staying anonymous to the DB. +export default { + fetch: withSupabase({ auth: 'publishable' }, async (_req, ctx) => { + // ctx.supabase — anonymous (anon role); RLS still applies + // ctx.userClaims, ctx.jwtClaims — null (no JWT) + // ctx.authMode === 'publishable', ctx.authKeyName === 'default' + const { data: catalog } = await ctx.supabase + .from('games') + .select('id, name, cover_url') + return Response.json(catalog) + }), +} +``` + +The mobile app sends the publishable key in the `apikey` header: + +```ts +const catalogEndpoint = 'https://.supabase.co/functions/v1/catalog' +const publishableKey = 'sb_publishable_...' + +await fetch(catalogEndpoint, { headers: { apikey: publishableKey } }) +``` + +> Unlike `auth: 'secret'`, the `supabase` client here is anonymous, not admin — RLS is the source of truth for what's visible. The publishable key acts as a coarse "this request came from a known client" gate; it isn't a user identity. + ### API key protected ```ts // An admin dashboard fetches the list of featured games to curate. // Secret key auth (not a user JWT) — supabaseAdmin bypasses RLS. export default { - fetch: withSupabase({ allow: 'secret' }, async (_req, ctx) => { + fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => { const { data: featuredGames } = await ctx.supabaseAdmin .from('featured_games') .select() @@ -104,8 +136,8 @@ export default { // Users view their own play stats from the app (JWT). // A backend service pulls stats for any user (secret key + user_id in body). export default { - fetch: withSupabase({ allow: ['user', 'secret'] }, async (req, ctx) => { - const callerIsUser = ctx.authType === 'user' + fetch: withSupabase({ auth: ['user', 'secret'] }, async (req, ctx) => { + const callerIsUser = ctx.authMode === 'user' if (callerIsUser) { // RLS-scoped — the database enforces "own stats only" @@ -130,7 +162,7 @@ export default { // A cron job refreshes the "popular this week" list every hour. // Named key ("cron") so it can be rotated without touching other services. export default { - fetch: withSupabase({ allow: 'secret:cron' }, async (_req, ctx) => { + fetch: withSupabase({ auth: 'secret:cron' }, async (_req, ctx) => { const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) const { data: popularThisWeek } = await ctx.supabaseAdmin.rpc( 'get_most_favorited_since', @@ -164,15 +196,15 @@ await fetch(refreshEndpoint, { | Mode | Credential | Use case | | ------------------ | --------------------- | --------------------------------------------------- | | `"user"` (default) | Valid JWT | Authenticated user endpoints | -| `"public"` | Valid publishable key | Client-facing, key-validated endpoints | +| `"publishable"` | Valid publishable key | Client-facing, key-validated endpoints | | `"secret"` | Valid secret key | Server-to-server, internal calls | -| `"always"` | None | Open endpoints, wrappers that handle their own auth | +| `"none"` | None | Open endpoints, wrappers that handle their own auth | -Array syntax (`allow: ["user", "secret"]`) accepts multiple auth methods — first match wins. An absent credential falls through to the next mode; a present-but-invalid JWT rejects the request (no silent downgrade). See [`docs/auth-modes.md`](docs/auth-modes.md). +Array syntax (`auth: ["user", "secret"]`) accepts multiple auth methods — first match wins. An absent credential falls through to the next mode; a present-but-invalid JWT rejects the request (no silent downgrade). See [`docs/auth-modes.md`](docs/auth-modes.md). -Named key validation: `allow: "public:web_app"` or `allow: "secret:automations"` validates against a specific named key in `SUPABASE_PUBLISHABLE_KEYS` or `SUPABASE_SECRET_KEYS`. +Named key validation: `auth: "publishable:web_app"` or `auth: "secret:automations"` validates against a specific named key in `SUPABASE_PUBLISHABLE_KEYS` or `SUPABASE_SECRET_KEYS`. -> **Supabase Edge Functions:** By default, the platform requires a valid JWT on every request. If your function uses `allow: 'public'`, `allow: 'secret'`, or `allow: 'always'`, disable the platform-level JWT check in `supabase/config.toml`: +> **Supabase Edge Functions:** By default, the platform requires a valid JWT on every request. If your function uses `auth: 'publishable'`, `auth: 'secret'`, or `auth: 'none'`, disable the platform-level JWT check in `supabase/config.toml`: > > ```toml > [functions.my-function] @@ -188,13 +220,13 @@ interface SupabaseContext { supabase: SupabaseClient // RLS-scoped (user or anon depending on auth) supabaseAdmin: SupabaseClient // Bypasses RLS userClaims: UserClaims | null // JWT-derived identity (for full User, call supabase.auth.getUser()) - claims: JWTClaims | null // Present when auth is JWT - authType: Allow // Which auth mode matched - authKeyName?: string | null // Auth key name of the API key that was used for this request + jwtClaims: JWTClaims | null // Present when auth is JWT + authMode: AuthMode // Which auth mode matched + authKeyName?: string // Auth key name of the API key that was used for this request (omitted for `'user'` / `'none'`) } ``` -`supabase` is always the safe client — it respects RLS. When `authType` is `"user"`, it's scoped to that user's permissions. Otherwise, it's initialized as anonymous. +`supabase` is always the safe client — it respects RLS. When `authMode` is `"user"`, it's scoped to that user's permissions. Otherwise, it's initialized as anonymous. `supabaseAdmin` always bypasses RLS. Use it for operations that need full database access. @@ -203,7 +235,7 @@ interface SupabaseContext { ```ts withSupabase( { - allow: 'user', // who can call this function + auth: 'user', // who can call this function cors: false, // disable CORS (default: supabase-js CORS headers) env: { url: '...' }, // env overrides (optional) }, @@ -216,7 +248,7 @@ withSupabase( ```ts withSupabase( { - allow: 'user', + auth: 'user', cors: { 'Access-Control-Allow-Origin': 'https://myapp.com', 'Access-Control-Allow-Headers': 'authorization, content-type', @@ -230,6 +262,13 @@ withSupabase( ## Framework Adapters +Adapters wrap `withSupabase` for a specific framework's middleware contract. **All adapters are community-maintained** — both Hono and H3 originated as community contributions. They live in this repo and ship with the core package, so a single `npm install @supabase/server` covers the framework you're using. See [`src/adapters/README.md`](src/adapters/README.md) for the maintenance model and the requirements for contributing a new adapter. + +| Framework | Import | Framework version | Docs | +| --------- | -------------------------------- | ----------------- | ---------------------------------------------- | +| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) | +| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) | + ### Hono ```ts @@ -237,21 +276,12 @@ import { Hono } from 'hono' import { withSupabase } from '@supabase/server/adapters/hono' const app = new Hono() - -// Protected — withSupabase middleware validates the JWT before the handler runs -app.get('/games', withSupabase({ allow: 'user' }), async (c) => { - const { supabase } = c.var.supabaseContext - const { data: myGames } = await supabase.from('favorite_games').select() - return c.json(myGames) -}) - -// Public — no middleware means no auth -app.get('/health', (c) => c.json({ status: 'ok' })) +app.use('*', withSupabase({ auth: 'user' })) export default { fetch: app.fetch } ``` -The adapter does not handle CORS — use `hono/cors` for that. Per-route auth works naturally by applying the middleware to specific routes. +See [docs/adapters/hono.md](docs/adapters/hono.md) for per-route auth, CORS, error handling, and other patterns. ### H3 / Nuxt @@ -260,48 +290,12 @@ import { H3 } from 'h3' import { withSupabase } from '@supabase/server/adapters/h3' const app = new H3() - -// Protected — withSupabase validates the JWT before the handler runs -app.use(withSupabase({ allow: 'user' })) - -app.get('/games', async (event) => { - const { supabase } = event.context.supabaseContext - const { data: myGames } = await supabase.from('favorite_games').select() - return myGames -}) - -// Public — no middleware means no auth -app.get('/health', () => ({ status: 'ok' })) +app.use(withSupabase({ auth: 'user' })) export default { fetch: app.fetch } ``` -For **Nuxt**, use `defineHandler` for file routes: - -```ts -// server/api/games.get.ts -import { defineHandler } from 'h3' -import { withSupabase } from '@supabase/server/adapters/h3' - -export default defineHandler({ - middleware: [withSupabase({ allow: 'user' })], - handler: async (event) => { - const { supabase } = event.context.supabaseContext - return supabase.from('favorite_games').select() - }, -}) -``` - -For app-wide auth, register it as a server middleware: - -```ts -// server/middleware/supabase.ts -import { withSupabase } from '@supabase/server/adapters/h3' - -export default withSupabase({ allow: 'user' }) -``` - -The adapter does not handle CORS — use H3's CORS utilities for that. +See [docs/adapters/h3.md](docs/adapters/h3.md) for per-route auth, Nuxt server-middleware patterns, CORS, and more. ## Primitives @@ -319,10 +313,10 @@ import { ### verifyAuth -Extracts credentials from a Request and validates against the allow config. +Extracts credentials from a Request and validates against the auth config. ```ts -const { data: auth, error } = await verifyAuth(req, { allow: 'user' }) +const { data: auth, error } = await verifyAuth(req, { auth: 'user' }) if (error) { return Response.json({ message: error.message }, { status: error.status }) } @@ -334,8 +328,8 @@ Low-level — works with raw credentials instead of a Request. Used by SSR adapt ```ts const credentials = { token: myToken, apikey: null } -const { data: auth, error } = await verifyCredentials(credentials, { - allow: 'user', +const { data: result, error } = await verifyCredentials(credentials, { + auth: 'user', }) ``` @@ -352,7 +346,7 @@ const adminClient = createAdminClient() // bypasses RLS entirely Full context assembly from a Request — `verifyAuth` + client creation in one call. ```ts -const { data: ctx, error } = await createSupabaseContext(req, { allow: 'user' }) +const { data: ctx, error } = await createSupabaseContext(req, { auth: 'user' }) ``` ### resolveEnv @@ -383,14 +377,14 @@ export default { // Protected — verify the JWT, then create a user-scoped client if (url.pathname === '/games') { - const { data: auth, error } = await verifyAuth(req, { allow: 'user' }) + const { data: result, error } = await verifyAuth(req, { auth: 'user' }) if (error) return Response.json( { message: error.message }, { status: error.status }, ) - const userScopedClient = createContextClient(auth.token) + const userScopedClient = createContextClient(result.token) const { data: myGames } = await userScopedClient .from('favorite_games') .select() @@ -431,7 +425,11 @@ For other environments, pass overrides via the `env` config option or `resolveEn - **Node.js** — use the [Hono adapter](#hono), [H3 adapter](#h3--nuxt), or [core primitives](#primitives) with your framework of choice. - **Cloudflare Workers** — enable `nodejs_compat` in `wrangler.toml` or pass env overrides via the `env` config option. - **Nuxt** — use the [H3 adapter](#h3--nuxt) directly as a server middleware. -- **Next.js / SvelteKit / Remix** — use core primitives to build a cookie-based auth adapter. See [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md). +- **Next.js / SvelteKit / Remix** — compose with [`@supabase/ssr`](https://github.com/supabase/ssr): `@supabase/ssr` owns cookies + refresh-token rotation, `@supabase/server` adds verified claims and typed RLS / admin clients on top. See [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md). + +### 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. The composable primitives already work in SSR environments but require more setup — see [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) for the Next.js example. The two packages coexist and are not replacements for each other. Deeper integration with `@supabase/ssr` is on the roadmap. ## Exports @@ -444,17 +442,19 @@ For other environments, pass overrides via the `env` config option or `resolveEn ## Documentation -| Question | Doc file | -| -------------------------------------------------------- | ---------------------------------------------------------------- | -| How do I create a basic endpoint? | [`docs/getting-started.md`](docs/getting-started.md) | -| What auth modes are available? Array syntax? Named keys? | [`docs/auth-modes.md`](docs/auth-modes.md) | -| How do I use this with Hono? | [`docs/hono-adapter.md`](docs/hono-adapter.md) | -| How do I use low-level primitives for custom flows? | [`docs/core-primitives.md`](docs/core-primitives.md) | -| How do environment variables work across runtimes? | [`docs/environment-variables.md`](docs/environment-variables.md) | -| How do I handle errors? What codes exist? | [`docs/error-handling.md`](docs/error-handling.md) | -| How do I get typed database queries? | [`docs/typescript-generics.md`](docs/typescript-generics.md) | -| How do I use this in Next.js, Nuxt, SvelteKit, or Remix? | [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) | -| What's the complete API surface? | [`docs/api-reference.md`](docs/api-reference.md) | +| Question | Doc file | +| ------------------------------------------------------------------- | ---------------------------------------------------------------- | +| How do I create a basic endpoint? | [`docs/getting-started.md`](docs/getting-started.md) | +| What auth modes are available? Array syntax? Named keys? | [`docs/auth-modes.md`](docs/auth-modes.md) | +| Which framework adapters exist? How do I contribute one? | [`src/adapters/README.md`](src/adapters/README.md) | +| How do I use this with Hono? | [`docs/adapters/hono.md`](docs/adapters/hono.md) | +| How do I use this with H3 / Nuxt? | [`docs/adapters/h3.md`](docs/adapters/h3.md) | +| How do I use low-level primitives for custom flows? | [`docs/core-primitives.md`](docs/core-primitives.md) | +| How do environment variables work across runtimes? | [`docs/environment-variables.md`](docs/environment-variables.md) | +| How do I handle errors? What codes exist? | [`docs/error-handling.md`](docs/error-handling.md) | +| How do I get typed database queries? | [`docs/typescript-generics.md`](docs/typescript-generics.md) | +| How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) | +| What's the complete API surface? | [`docs/api-reference.md`](docs/api-reference.md) | ## Development diff --git a/docs/adapters/h3.md b/docs/adapters/h3.md new file mode 100644 index 0000000..aba4fc9 --- /dev/null +++ b/docs/adapters/h3.md @@ -0,0 +1,180 @@ +# H3 / Nuxt Adapter + +## Setup + +Install H3 as a peer dependency: + +```bash +pnpm add h3 +``` + +The adapter exports its own `withSupabase` that returns H3 middleware instead of a fetch handler. Works with standalone H3 servers and Nuxt server routes (which run on H3 under the hood). + +## Basic app with auth + +```ts +import { H3 } from 'h3' +import { withSupabase } from '@supabase/server/adapters/h3' + +const app = new H3() + +// Apply auth to all routes +app.use(withSupabase({ auth: 'user' })) + +app.get('/todos', async (event) => { + const { supabase } = event.context.supabaseContext + const { data } = await supabase.from('todos').select() + return data +}) + +app.get('/profile', async (event) => { + const { supabase, userClaims } = event.context.supabaseContext + const { data } = await supabase + .from('profiles') + .select() + .eq('id', userClaims!.id) + return data +}) + +export default { fetch: app.fetch } +``` + +The context is stored in `event.context.supabaseContext` and contains the same `SupabaseContext` fields as the main `withSupabase` wrapper: `supabase`, `supabaseAdmin`, `userClaims`, `jwtClaims`, and `authMode`. + +## Per-route auth + +Apply different auth modes to different routes by attaching the middleware to specific handlers: + +```ts +import { H3 } from 'h3' +import { withSupabase } from '@supabase/server/adapters/h3' + +const app = new H3() + +// Public route — no auth +app.get('/health', () => ({ status: 'ok' })) + +// User-authenticated route +app.get('/todos', withSupabase({ auth: 'user' }), async (event) => { + const { supabase } = event.context.supabaseContext + const { data } = await supabase.from('todos').select() + return data +}) + +// Secret-key-protected admin route +app.post('/admin/sync', withSupabase({ auth: 'secret' }), async (event) => { + const { supabaseAdmin } = event.context.supabaseContext + const { data } = await supabaseAdmin + .from('audit_log') + .insert({ action: 'sync' }) + return data +}) + +// Dual auth — users or services +app.get( + '/reports', + withSupabase({ auth: ['user', 'secret'] }), + async (event) => { + const { authMode } = event.context.supabaseContext + return { authMode } + }, +) + +export default { fetch: app.fetch } +``` + +## Nuxt: file-based routes + +Use `defineHandler` to attach auth to a single Nuxt server route: + +```ts +// server/api/games.get.ts +import { defineHandler } from 'h3' +import { withSupabase } from '@supabase/server/adapters/h3' + +export default defineHandler({ + middleware: [withSupabase({ auth: 'user' })], + handler: async (event) => { + const { supabase } = event.context.supabaseContext + return supabase.from('favorite_games').select() + }, +}) +``` + +## Nuxt: app-wide auth + +Register as a server middleware to apply auth to every route: + +```ts +// server/middleware/supabase.ts +import { withSupabase } from '@supabase/server/adapters/h3' + +export default withSupabase({ auth: 'user' }) +``` + +## Skip behavior + +If a previous middleware already set `event.context.supabaseContext`, subsequent `withSupabase` calls skip auth. This enables a pattern where route-level middleware overrides the app-wide default: + +```ts +const app = new H3() + +// App-wide: require user auth +app.use(withSupabase({ auth: 'user' })) + +// This route needs secret auth instead. +// The route-level middleware runs first, sets the context, +// and the app-wide middleware skips. +app.post('/webhook', withSupabase({ auth: 'secret' }), async (event) => { + const { supabaseAdmin } = event.context.supabaseContext + // ... +}) +``` + +## CORS + +The H3 adapter does not handle CORS — the `cors` option is excluded from its config type. Use H3's built-in CORS utilities: + +```ts +import { H3, handleCors } from 'h3' +import { withSupabase } from '@supabase/server/adapters/h3' + +const app = new H3() + +app.use((event) => handleCors(event, { origin: '*' })) +app.use(withSupabase({ auth: 'user' })) + +app.get('/todos', async (event) => { + const { supabase } = event.context.supabaseContext + const { data } = await supabase.from('todos').select() + return data +}) + +export default { fetch: app.fetch } +``` + +## Environment overrides + +Pass `env` to override auto-detected environment variables, same as the main wrapper: + +```ts +app.use( + withSupabase({ + auth: 'user', + env: { url: 'http://localhost:54321' }, + }), +) +``` + +## Supabase client options + +Forward options to the underlying `createClient()` calls: + +```ts +app.use( + withSupabase({ + auth: 'user', + supabaseOptions: { db: { schema: 'api' } }, + }), +) +``` diff --git a/docs/hono-adapter.md b/docs/adapters/hono.md similarity index 88% rename from docs/hono-adapter.md rename to docs/adapters/hono.md index ea85274..78d5119 100644 --- a/docs/hono-adapter.md +++ b/docs/adapters/hono.md @@ -19,7 +19,7 @@ import { withSupabase } from '@supabase/server/adapters/hono' const app = new Hono() // Apply auth to all routes -app.use('*', withSupabase({ allow: 'user' })) +app.use('*', withSupabase({ auth: 'user' })) app.get('/todos', async (c) => { const { supabase } = c.var.supabaseContext @@ -39,7 +39,7 @@ app.get('/profile', async (c) => { export default { fetch: app.fetch } ``` -The context is stored in `c.var.supabaseContext` and contains the same `SupabaseContext` fields as the main `withSupabase` wrapper: `supabase`, `supabaseAdmin`, `userClaims`, `claims`, and `authType`. +The context is stored in `c.var.supabaseContext` and contains the same `SupabaseContext` fields as the main `withSupabase` wrapper: `supabase`, `supabaseAdmin`, `userClaims`, `jwtClaims`, and `authMode`. ## Per-route auth @@ -55,14 +55,14 @@ const app = new Hono() app.get('/health', (c) => c.json({ status: 'ok' })) // User-authenticated route -app.get('/todos', withSupabase({ allow: 'user' }), async (c) => { +app.get('/todos', withSupabase({ auth: 'user' }), async (c) => { const { supabase } = c.var.supabaseContext const { data } = await supabase.from('todos').select() return c.json(data) }) // Secret-key-protected admin route -app.post('/admin/sync', withSupabase({ allow: 'secret' }), async (c) => { +app.post('/admin/sync', withSupabase({ auth: 'secret' }), async (c) => { const { supabaseAdmin } = c.var.supabaseContext const { data } = await supabaseAdmin .from('audit_log') @@ -71,9 +71,9 @@ app.post('/admin/sync', withSupabase({ allow: 'secret' }), async (c) => { }) // Dual auth — users or services -app.get('/reports', withSupabase({ allow: ['user', 'secret'] }), async (c) => { - const { supabase, authType } = c.var.supabaseContext - return c.json({ authType }) +app.get('/reports', withSupabase({ auth: ['user', 'secret'] }), async (c) => { + const { supabase, authMode } = c.var.supabaseContext + return c.json({ authMode }) }) export default { fetch: app.fetch } @@ -99,7 +99,7 @@ import { withSupabase } from '@supabase/server/adapters/hono' const app = new Hono() app.use('*', cors()) -app.use('*', withSupabase({ allow: 'user' })) +app.use('*', withSupabase({ auth: 'user' })) app.get('/todos', async (c) => { const { supabase } = c.var.supabaseContext @@ -122,7 +122,7 @@ import { AuthError } from '@supabase/server' const app = new Hono() -app.use('*', withSupabase({ allow: 'user' })) +app.use('*', withSupabase({ auth: 'user' })) // Custom error handler app.onError((err, c) => { @@ -153,7 +153,7 @@ Pass `env` to override auto-detected environment variables, same as the main wra app.use( '*', withSupabase({ - allow: 'user', + auth: 'user', env: { url: 'http://localhost:54321' }, }), ) @@ -167,7 +167,7 @@ Forward options to the underlying `createClient()` calls: app.use( '*', withSupabase({ - allow: 'user', + auth: 'user', supabaseOptions: { db: { schema: 'api' } }, }), ) diff --git a/docs/api-reference.md b/docs/api-reference.md index b360e03..5fdf7c0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -18,7 +18,7 @@ function withSupabase( Wraps a fetch handler with auth, CORS, and client creation. Returns a `(req: Request) => Promise` function suitable for `export default { fetch }`. - Handles `OPTIONS` preflight when CORS is enabled -- Verifies credentials per `config.allow` +- Verifies credentials per `config.auth` - Returns JSON error response on auth failure - Adds CORS headers to all responses @@ -36,7 +36,7 @@ function createSupabaseContext( Creates a `SupabaseContext` from a request. Returns a result tuple. The `cors` option is ignored. -Defaults to `allow: 'user'` when `options` is omitted. +Defaults to `auth: 'user'` when `options` is omitted. --- @@ -47,7 +47,10 @@ Defaults to `allow: 'user'` when `options` is omitted. ```ts function verifyAuth( request: Request, - options: { allow: AllowWithKey | AllowWithKey[]; env?: Partial }, + options: { + auth?: AuthModeWithKey | AuthModeWithKey[] + env?: Partial + }, ): Promise<{ data: AuthResult; error: null } | { data: null; error: AuthError }> ``` @@ -58,7 +61,10 @@ Extracts credentials from a request and verifies them. Convenience wrapper over ```ts function verifyCredentials( credentials: Credentials, - options: { allow: AllowWithKey | AllowWithKey[]; env?: Partial }, + options: { + auth?: AuthModeWithKey | AuthModeWithKey[] + env?: Partial + }, ): Promise<{ data: AuthResult; error: null } | { data: null; error: AuthError }> ``` @@ -124,25 +130,29 @@ Hono middleware. Sets `c.var.supabaseContext` on the Hono context. Throws `HTTPE Skips if `c.var.supabaseContext` is already set (enables route-level overrides). -Defaults to `allow: 'user'` when config is omitted. +Defaults to `auth: 'user'` when config is omitted. --- ## Types -### Allow +### AuthMode ```ts -type Allow = 'always' | 'public' | 'secret' | 'user' +type AuthMode = 'none' | 'publishable' | 'secret' | 'user' ``` -### AllowWithKey +### AuthModeWithKey ```ts -type AllowWithKey = Allow | `public:${string}` | `secret:${string}` +type AuthModeWithKey = AuthMode | `publishable:${string}` | `secret:${string}` ``` -Extended auth mode with named key support. Examples: `'public:web'`, `'secret:*'`, `'secret:internal'`. +Extended auth mode with named key support. Examples: `'publishable:web'`, `'secret:*'`, `'secret:internal'`. + +### Allow / AllowWithKey (deprecated aliases) + +`Allow` and `AllowWithKey` are kept as deprecated aliases for `AuthMode` and `AuthModeWithKey`. Prefer the `Auth*` names — the legacy ones will be removed in a future major release. ### SupabaseContext\ @@ -151,8 +161,9 @@ interface SupabaseContext { supabase: SupabaseClient supabaseAdmin: SupabaseClient userClaims: UserClaims | null - claims: JWTClaims | null - authType: Allow + jwtClaims: JWTClaims | null + authMode: AuthMode + authKeyName?: string } ``` @@ -160,7 +171,9 @@ interface SupabaseContext { ```ts interface WithSupabaseConfig { - allow?: AllowWithKey | AllowWithKey[] // default: 'user' + auth?: AuthModeWithKey | AuthModeWithKey[] // default: 'user' + /** @deprecated use `auth` instead — will be removed in a future major release */ + allow?: AuthModeWithKey | AuthModeWithKey[] env?: Partial cors?: boolean | Record // default: true supabaseOptions?: SupabaseClientOptions @@ -191,10 +204,10 @@ interface Credentials { ```ts interface AuthResult { - authType: Allow + authMode: AuthMode token: string | null userClaims: UserClaims | null - claims: JWTClaims | null + jwtClaims: JWTClaims | null keyName?: string | null } ``` diff --git a/docs/auth-modes.md b/docs/auth-modes.md index 36e2331..3c9c834 100644 --- a/docs/auth-modes.md +++ b/docs/auth-modes.md @@ -2,17 +2,21 @@ ## Overview -Every request is validated against one or more auth modes before your handler runs. The `allow` config determines which modes are accepted. +Every request is validated against one or more auth modes before your handler runs. The `auth` config determines which modes are accepted. -| Mode | Credential required | Typical use case | -| ---------- | -------------------------------------------- | -------------------------------------- | -| `'user'` | Valid JWT in `Authorization: Bearer ` | Authenticated user endpoints | -| `'public'` | Valid publishable key in `apikey` header | Client-facing, key-validated endpoints | -| `'secret'` | Valid secret key in `apikey` header | Server-to-server, internal calls | -| `'always'` | None | Open endpoints, custom auth wrappers | +> **`allow` is deprecated.** The `auth` option replaces the legacy `allow` option. `allow` still works (with a one-time `console.warn`) but will be removed in a future major release. Migration is a find-and-replace: `allow:` → `auth:`. + +> **Breaking — auth API renamed.** `'always'` is now `'none'` and `'public'` is now `'publishable'` (including the colon variants `'public:'` → `'publishable:'`). The field on `AuthResult` and `SupabaseContext` was also renamed from `authType` to `authMode` so it matches the `AuthMode` type. The old names no longer work — update the option values you pass in **and** any runtime checks on `ctx.authType` (now `ctx.authMode`). + +| Mode | Credential required | Typical use case | +| --------------- | -------------------------------------------- | -------------------------------------- | +| `'user'` | Valid JWT in `Authorization: Bearer ` | Authenticated user endpoints | +| `'publishable'` | Valid publishable key in `apikey` header | Client-facing, key-validated endpoints | +| `'secret'` | Valid secret key in `apikey` header | Server-to-server, internal calls | +| `'none'` | None | Open endpoints, custom auth wrappers | > **Supabase Edge Functions:** By default, the platform requires a valid JWT on every request same as `'user'`. -> If your function uses `'public'`, `'secret'` or `'always'`, disable the platform-level JWT check in `supabase/config.toml`: +> If your function uses `'publishable'`, `'secret'` or `'none'`, disable the platform-level JWT check in `supabase/config.toml`: > > ```toml > [functions.my-function] @@ -27,15 +31,15 @@ The default. Verifies the JWT using your project's JWKS (JSON Web Key Set). import { withSupabase } from '@supabase/server' export default { - fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => { + fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { // ctx.userClaims has the caller's identity console.log(ctx.userClaims!.id) // "d0f1a2b3-..." console.log(ctx.userClaims!.email) // "user@example.com" console.log(ctx.userClaims!.role) // "authenticated" - // ctx.claims has the raw JWT payload - console.log(ctx.claims!.sub) // same as userClaims.id - console.log(ctx.claims!.exp) // token expiration (epoch seconds) + // ctx.jwtClaims has the raw JWT payload + console.log(ctx.jwtClaims!.sub) // same as userClaims.id + console.log(ctx.jwtClaims!.exp) // token expiration (epoch seconds) // ctx.supabase is scoped to this user — RLS applies const { data } = await ctx.supabase.from('todos').select() @@ -52,7 +56,7 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIs... **`userClaims` vs `supabase.auth.getUser()`:** `userClaims` is extracted from the JWT and is available instantly — no network call. It includes `id`, `email`, `role`, `appMetadata`, and `userMetadata`. For the full Supabase `User` object (email confirmation status, providers, linked identities), call `ctx.supabase.auth.getUser()`, which makes a request to the auth server. -## Public mode +## Publishable mode Validates that the `apikey` header contains a recognized publishable key. Uses timing-safe comparison to prevent timing attacks. See [`security.md`](security.md) for details. @@ -60,7 +64,7 @@ Validates that the `apikey` header contains a recognized publishable key. Uses t import { withSupabase } from '@supabase/server' export default { - fetch: withSupabase({ allow: 'public' }, async (_req, ctx) => { + fetch: withSupabase({ auth: 'publishable' }, async (_req, ctx) => { // ctx.userClaims is null — no JWT involved // ctx.supabase is initialized as anonymous (RLS anon role) const { data } = await ctx.supabase.from('products').select() @@ -75,17 +79,17 @@ The caller must send: apikey: sb_publishable_abc123... ``` -By default, `public` mode validates against the `"default"` key in `SUPABASE_PUBLISHABLE_KEYS`. Use named key syntax to target a specific key (see below). +By default, `publishable` mode validates against the `"default"` key in `SUPABASE_PUBLISHABLE_KEYS`. Use named key syntax to target a specific key (see below). ## Secret mode -Validates that the `apikey` header contains a recognized secret key. Same timing-safe comparison as public mode. See [`security.md`](security.md) for details. +Validates that the `apikey` header contains a recognized secret key. Same timing-safe comparison as publishable mode. See [`security.md`](security.md) for details. ```ts import { withSupabase } from '@supabase/server' export default { - fetch: withSupabase({ allow: 'secret' }, async (_req, ctx) => { + fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => { // ctx.supabaseAdmin bypasses RLS — use for privileged operations const { data } = await ctx.supabaseAdmin.from('config').select() return Response.json(data) @@ -99,7 +103,7 @@ The caller must send: apikey: sb_secret_xyz789... ``` -## Always mode +## None mode No credentials required. Every request is accepted. @@ -107,8 +111,8 @@ No credentials required. Every request is accepted. import { withSupabase } from '@supabase/server' export default { - fetch: withSupabase({ allow: 'always' }, async (_req, ctx) => { - // ctx.authType is 'always' + fetch: withSupabase({ auth: 'none' }, async (_req, ctx) => { + // ctx.authMode is 'none' // ctx.userClaims is null // ctx.supabase is anonymous (RLS anon role) return Response.json({ status: 'healthy' }) @@ -116,7 +120,7 @@ export default { } ``` -Use `always` for health checks, public APIs, or when you handle auth yourself inside the handler. +Use `none` for health checks, public APIs, or when you handle auth yourself inside the handler. ## Array syntax (multiple modes) @@ -126,9 +130,9 @@ Accept multiple auth methods. Modes are tried in order — the first match wins. import { withSupabase } from '@supabase/server' export default { - fetch: withSupabase({ allow: ['user', 'secret'] }, async (req, ctx) => { - // ctx.authType tells you which mode matched - if (ctx.authType === 'user') { + fetch: withSupabase({ auth: ['user', 'secret'] }, async (req, ctx) => { + // ctx.authMode tells you which mode matched + if (ctx.authMode === 'user') { // Called by an authenticated user const { data } = await ctx.supabase.from('reports').select() return Response.json(data) @@ -147,7 +151,7 @@ export default { A request with a valid JWT matches `'user'`. A request with a valid secret key matches `'secret'`. A request with neither is rejected. -**Fallthrough vs rejection.** A mode is only "tried" when its credential is actually present. A request with no `Authorization` header moves on to the next mode. But if a JWT _is_ present and fails verification (malformed, expired, wrong signature, or missing a `sub` claim), the request is rejected immediately with `InvalidCredentialsError` — it will not silently fall through to `'public'`, `'secret'`, or `'always'`. The same rule applies on the API-key side: `'public'` and `'secret'` fall through only when no `apikey` header is sent. This prevents a bad credential from being downgraded to a less-privileged auth mode. +**Fallthrough vs rejection.** A mode is only "tried" when its credential is actually present. A request with no `Authorization` header moves on to the next mode. But if a JWT _is_ present and fails verification (malformed, expired, wrong signature, or missing a `sub` claim), the request is rejected immediately with `InvalidCredentialsError` — it will not silently fall through to `'publishable'`, `'secret'`, or `'none'`. The same rule applies on the API-key side: `'publishable'` and `'secret'` fall through only when no `apikey` header is sent. This prevents a bad credential from being downgraded to a less-privileged auth mode. ## Named key syntax @@ -167,39 +171,39 @@ Keys are stored as a JSON object in `SUPABASE_PUBLISHABLE_KEYS` or `SUPABASE_SEC ```ts // Only accept the "web" publishable key -withSupabase({ allow: 'public:web' }, handler) +withSupabase({ auth: 'publishable:web' }, handler) // Only accept the "internal" secret key -withSupabase({ allow: 'secret:internal' }, handler) +withSupabase({ auth: 'secret:internal' }, handler) ``` ### Wildcard — accept any key in the set ```ts // Accept any publishable key -withSupabase({ allow: 'public:*' }, handler) +withSupabase({ auth: 'publishable:*' }, handler) // Accept any secret key -withSupabase({ allow: 'secret:*' }, handler) +withSupabase({ auth: 'secret:*' }, handler) ``` ### Which key matched? -When using named keys, `ctx.authType` tells you the mode and `keyName` on the `AuthResult` (from core primitives) tells you which key matched. In the high-level `withSupabase` wrapper, the matched key is used internally for client creation. +When using named keys, `ctx.authMode` tells you the mode and `keyName` on the `AuthResult` (from core primitives) tells you which key matched. In the high-level `withSupabase` wrapper, the matched key is used internally for client creation. ### Combining named keys with other modes ```ts -withSupabase({ allow: ['user', 'public:web'] }, async (_req, ctx) => { +withSupabase({ auth: ['user', 'publishable:web'] }, async (_req, ctx) => { // Accepts either a valid JWT or the "web" publishable key - return Response.json({ authType: ctx.authType }) + return Response.json({ authMode: ctx.authMode }) }) ``` ## How auth flows through the system 1. `extractCredentials(request)` reads `Authorization: Bearer ` and `apikey` from headers -2. Each mode in `allow` is tried in order against the extracted credentials -3. First match wins — returns an `AuthResult` with `authType`, `token`, `userClaims`, `claims`, and `keyName`. A mode falls through to the next only when its credential is absent; a credential that is present but invalid terminates the chain with `InvalidCredentialsError`. +2. Each mode in `auth` is tried in order against the extracted credentials +3. First match wins — returns an `AuthResult` with `authMode`, `token`, `userClaims`, `jwtClaims`, and `keyName`. A mode falls through to the next only when its credential is absent; a credential that is present but invalid terminates the chain with `InvalidCredentialsError`. 4. The auth result is used to create scoped clients (`supabase` with the user's token, `supabaseAdmin` with the secret key) 5. Everything is bundled into a `SupabaseContext` and passed to your handler diff --git a/docs/core-primitives.md b/docs/core-primitives.md index 3d3b07f..5a280cd 100644 --- a/docs/core-primitives.md +++ b/docs/core-primitives.md @@ -19,7 +19,7 @@ The primitives compose into a pipeline. Each step is independent — use only wh ``` resolveEnv() → SupabaseEnv extractCredentials(request) → Credentials { token, apikey } -verifyCredentials(credentials, opts) → AuthResult { authType, token, userClaims, claims, keyName } +verifyCredentials(credentials, opts) → AuthResult { authMode, token, userClaims, jwtClaims, keyName } createContextClient(options) → SupabaseClient (RLS-scoped) createAdminClient(options) → SupabaseClient (bypasses RLS) ``` @@ -77,14 +77,14 @@ import { verifyCredentials } from '@supabase/server/core' const credentials = { token: cookieToken, apikey: null } const { data: auth, error } = await verifyCredentials(credentials, { - allow: 'user', + auth: 'user', }) if (error) { return Response.json({ message: error.message }, { status: error.status }) } -console.log(auth!.authType) // 'user' +console.log(auth!.authMode) // 'user' console.log(auth!.userClaims) // { id: '...', email: '...', role: 'authenticated' } ``` @@ -93,17 +93,17 @@ Supports all auth mode syntax — single mode, arrays, and named keys: ```ts // Multiple modes const { data: auth } = await verifyCredentials(creds, { - allow: ['user', 'public'], + auth: ['user', 'publishable'], }) // Named key const { data: auth } = await verifyCredentials(creds, { - allow: 'public:web', + auth: 'publishable:web', }) // Wildcard const { data: auth } = await verifyCredentials(creds, { - allow: 'secret:*', + auth: 'secret:*', }) ``` @@ -115,7 +115,7 @@ Convenience function that combines `extractCredentials` and `verifyCredentials` import { verifyAuth } from '@supabase/server/core' const { data: auth, error } = await verifyAuth(request, { - allow: 'user', + auth: 'user', }) if (error) { @@ -134,7 +134,7 @@ Creates a Supabase client scoped to the caller's identity. RLS policies apply. import { verifyAuth, createContextClient } from '@supabase/server/core' // With a user's token (from verifyAuth) -const { data: auth } = await verifyAuth(request, { allow: 'user' }) +const { data: auth } = await verifyAuth(request, { auth: 'user' }) const supabase = createContextClient({ auth: { token: auth!.token, keyName: auth!.keyName }, }) @@ -194,7 +194,7 @@ export default { // User-authenticated route if (url.pathname === '/todos') { - const { data: auth, error } = await verifyAuth(req, { allow: 'user' }) + const { data: auth, error } = await verifyAuth(req, { auth: 'user' }) if (error) { return Response.json( { message: error.message }, @@ -212,7 +212,7 @@ export default { // Admin route — secret key only if (url.pathname === '/admin/users') { const { data: auth, error } = await verifyAuth(req, { - allow: 'secret', + auth: 'secret', }) if (error) { return Response.json( @@ -233,8 +233,8 @@ export default { } ``` -## SSR frameworks (Next.js, Nuxt, SvelteKit, Remix) +## Cookie-based environments (with `@supabase/ssr`) -In SSR frameworks, the JWT lives in session cookies rather than the `Authorization` header. Use `verifyCredentials` with a token extracted from cookies, then create clients as usual. This is the key primitive that enables SSR integration — it accepts pre-extracted credentials from any source. +In Next.js, SvelteKit, Remix, and other cookie-based frameworks, the JWT lives in session cookies rather than the `Authorization` header. The recommended pattern is to **compose with [`@supabase/ssr`](https://github.com/supabase/ssr)**: let `@supabase/ssr` own the cookie session lifecycle and refresh-token rotation (via middleware), then hand its fresh access token to `verifyCredentials` and build typed clients with `createContextClient` + `createAdminClient`. -For a complete guide with cookie parsing, JWKS caching, env bridging, and full framework adapters, see [ssr-frameworks.md](ssr-frameworks.md). +For the full pattern — middleware setup, the composed adapter, JWKS caching, and other-framework adapting tips — see [ssr-frameworks.md](ssr-frameworks.md). diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 9a41b2c..0af05d0 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -15,12 +15,12 @@ On Supabase Platform and Local Development (CLI), all variables are auto-provisi Set these based on which auth modes your app uses: -| Variable | Required when | -| -------------------------- | ------------------------------------------ | -| `SUPABASE_URL` | Always | -| `SUPABASE_SECRET_KEY` | `allow: 'secret'` or using `supabaseAdmin` | -| `SUPABASE_PUBLISHABLE_KEY` | `allow: 'public'` | -| `SUPABASE_JWKS` | `allow: 'user'` (JWT verification) | +| Variable | Required when | +| -------------------------- | ----------------------------------------- | +| `SUPABASE_URL` | Always | +| `SUPABASE_SECRET_KEY` | `auth: 'secret'` or using `supabaseAdmin` | +| `SUPABASE_PUBLISHABLE_KEY` | `auth: 'publishable'` | +| `SUPABASE_JWKS` | `auth: 'user'` (JWT verification) | ### Minimal `.env` example @@ -48,10 +48,10 @@ You can then validate against specific keys with named key syntax: ```ts // Only accept the "web" publishable key -withSupabase({ allow: 'public:web' }, handler) +withSupabase({ auth: 'publishable:web' }, handler) // Accept any secret key -withSupabase({ allow: 'secret:*' }, handler) +withSupabase({ auth: 'secret:*' }, handler) ``` ### Singular form — equivalent to a single "default" key @@ -69,7 +69,7 @@ SUPABASE_PUBLISHABLE_KEY=sb_publishable_default_abc SUPABASE_PUBLISHABLE_KEYS={"default":"sb_publishable_default_abc"} ``` -The singular form is a convenience for the common case where you only have one key. The SDK stores it internally as `{ default: "" }`, so `allow: 'public'` (which looks for the `"default"` key) works with both forms. +The singular form is a convenience for the common case where you only have one key. The SDK stores it internally as `{ default: "" }`, so `auth: 'publishable'` (which looks for the `"default"` key) works with both forms. ### Priority @@ -87,7 +87,7 @@ SUPABASE_JWKS={"keys":[{"kty":"RSA","n":"...","e":"AQAB"}]} SUPABASE_JWKS=[{"kty":"RSA","n":"...","e":"AQAB"}] ``` -When `SUPABASE_JWKS` is not set, JWT verification (`allow: 'user'`) is unavailable. +When `SUPABASE_JWKS` is not set, JWT verification (`auth: 'user'`) is unavailable. ## Runtime-specific behavior @@ -119,7 +119,7 @@ Cloudflare Workers don't expose `Deno.env` or `process.env` by default. Two opti ```ts withSupabase( { - allow: 'user', + auth: 'user', env: { url: env.SUPABASE_URL, publishableKeys: { default: env.SUPABASE_PUBLISHABLE_KEY }, @@ -140,7 +140,7 @@ import { withSupabase } from '@supabase/server' export default { fetch: withSupabase( { - allow: 'user', + auth: 'user', env: { url: 'http://localhost:54321', // override just the URL }, diff --git a/docs/error-handling.md b/docs/error-handling.md index 20d495f..7b7190d 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -62,7 +62,7 @@ import { createSupabaseContext } from '@supabase/server' export default { fetch: async (req: Request) => { const { data: ctx, error } = await createSupabaseContext(req, { - allow: 'user', + auth: 'user', }) if (error) { @@ -93,7 +93,7 @@ import { withSupabase } from '@supabase/server/adapters/hono' const app = new Hono() -app.use('*', withSupabase({ allow: 'user' })) +app.use('*', withSupabase({ auth: 'user' })) app.onError((err, c) => { if (err instanceof HTTPException && err.cause) { @@ -115,7 +115,7 @@ Result-tuple functions: import { verifyAuth, resolveEnv } from '@supabase/server/core' // verifyAuth returns { data, error } -const { data: auth, error } = await verifyAuth(request, { allow: 'user' }) +const { data: auth, error } = await verifyAuth(request, { auth: 'user' }) if (error) { return Response.json({ message: error.message }, { status: error.status }) } @@ -137,7 +137,7 @@ import { } from '@supabase/server/core' import { EnvError } from '@supabase/server' -const { data: auth, error } = await verifyAuth(request, { allow: 'user' }) +const { data: auth, error } = await verifyAuth(request, { auth: 'user' }) // ... handle error ... try { diff --git a/docs/getting-started.md b/docs/getting-started.md index e38d114..b8a20b7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -32,14 +32,14 @@ The fastest way to get a working authenticated endpoint: import { withSupabase } from '@supabase/server' export default { - fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => { + fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { const { data } = await ctx.supabase.from('todos').select() return Response.json(data) }), } ``` -> The `export default { fetch }` pattern is the standard module worker interface supported by Deno (including Supabase Edge Functions), Bun, and Cloudflare Workers. For Node.js, use the [Hono adapter](hono-adapter.md) or [core primitives](core-primitives.md) with your framework of choice. +> The `export default { fetch }` pattern is the standard module worker interface supported by Deno (including Supabase Edge Functions), Bun, and Cloudflare Workers. For Node.js, use the [Hono adapter](adapters/hono.md) or [core primitives](core-primitives.md) with your framework of choice. This single wrapper does four things for every request: @@ -56,13 +56,13 @@ Your handler only runs when auth succeeds. import { withSupabase } from '@supabase/server' export default { - fetch: withSupabase({ allow: 'always' }, async (_req, _ctx) => { + fetch: withSupabase({ auth: 'none' }, async (_req, _ctx) => { return Response.json({ status: 'ok', time: new Date().toISOString() }) }), } ``` -> **Supabase Edge Functions:** By default, the platform requires a valid JWT on every request. If your function uses `allow: 'public'`, `allow: 'secret'`, or `allow: 'always'`, disable the platform-level JWT check in `supabase/config.toml`: +> **Supabase Edge Functions:** By default, the platform requires a valid JWT on every request. If your function uses `auth: 'publishable'`, `auth: 'secret'`, or `auth: 'none'`, disable the platform-level JWT check in `supabase/config.toml`: > > ```toml > [functions.my-function] @@ -73,16 +73,16 @@ export default { Every handler receives a `SupabaseContext` with these fields: -| Field | Type | Description | -| --------------- | -------------------- | ------------------------------------------------------------------------------------------------------ | -| `supabase` | `SupabaseClient` | Client scoped to the caller. RLS policies apply. | -| `supabaseAdmin` | `SupabaseClient` | Admin client. Bypasses RLS. | -| `userClaims` | `UserClaims \| null` | JWT-derived identity (`id`, `email`, `role`, `appMetadata`, `userMetadata`). `null` for non-user auth. | -| `claims` | `JWTClaims \| null` | Raw JWT payload (snake_case). `null` for non-user auth. | -| `authType` | `Allow` | Which auth mode matched: `'user'`, `'public'`, `'secret'`, or `'always'`. | -| `authKeyName` | `string \| null` | Which auth key name of the API key that was used. | +| Field | Type | Description | +| --------------- | --------------------- | ------------------------------------------------------------------------------------------------------ | +| `supabase` | `SupabaseClient` | Client scoped to the caller. RLS policies apply. | +| `supabaseAdmin` | `SupabaseClient` | Admin client. Bypasses RLS. | +| `userClaims` | `UserClaims \| null` | JWT-derived identity (`id`, `email`, `role`, `appMetadata`, `userMetadata`). `null` for non-user auth. | +| `jwtClaims` | `JWTClaims \| null` | Raw JWT payload (snake_case). `null` for non-user auth. | +| `authMode` | `AuthMode` | Which auth mode matched: `'user'`, `'publishable'`, `'secret'`, or `'none'`. | +| `authKeyName` | `string \| undefined` | Which auth key name of the API key that was used. Omitted for `'user'` / `'none'`. | -The `supabase` client respects Row-Level Security. When `authType` is `'user'`, the client is scoped to that user's permissions. For other auth modes, it's initialized as anonymous. +The `supabase` client respects Row-Level Security. When `authMode` is `'user'`, the client is scoped to that user's permissions. For other auth modes, it's initialized as anonymous. The `supabaseAdmin` client always bypasses RLS. Use it for operations that need full database access regardless of who's calling. @@ -98,7 +98,7 @@ import { createSupabaseContext } from '@supabase/server' export default { fetch: async (req: Request) => { const { data: ctx, error } = await createSupabaseContext(req, { - allow: 'user', + auth: 'user', }) if (error) { @@ -124,7 +124,7 @@ CORS is enabled by default with standard supabase-js headers. You can customize // Custom CORS headers withSupabase( { - allow: 'user', + auth: 'user', cors: { 'Access-Control-Allow-Origin': 'https://myapp.com', 'Access-Control-Allow-Headers': 'authorization, content-type', @@ -134,7 +134,7 @@ withSupabase( ) // Disable CORS (e.g., when a framework handles it) -withSupabase({ allow: 'user', cors: false }, handler) +withSupabase({ auth: 'user', cors: false }, handler) ``` ## Runtimes @@ -143,7 +143,7 @@ withSupabase({ allow: 'user', cors: false }, handler) - **Supabase Edge Functions** — environment variables are automatically injected by the platform. Zero config needed. - **Deno / Bun** — works out of the box with the module worker pattern. -- **Node.js** — set variables via `.env` files or your hosting platform. Use the [Hono adapter](hono-adapter.md) or [core primitives](core-primitives.md) to integrate with any framework. +- **Node.js** — set variables via `.env` files or your hosting platform. Use the [Hono adapter](adapters/hono.md) or [core primitives](core-primitives.md) to integrate with any framework. - **Cloudflare Workers** — enable `nodejs_compat` or pass env overrides via the `env` config option. For full details on environment setup per runtime, see [environment-variables.md](environment-variables.md). diff --git a/docs/security.md b/docs/security.md index 6d5a02b..bd39e41 100644 --- a/docs/security.md +++ b/docs/security.md @@ -12,8 +12,8 @@ The package uses a **double-HMAC technique**: both strings are HMAC'd with a ran This applies to: -- **Publishable key verification** (`allow: 'public'`) — compares the `apikey` header against stored publishable keys -- **Secret key verification** (`allow: 'secret'`) — compares the `apikey` header against stored secret keys +- **Publishable key verification** (`auth: 'publishable'`) — compares the `apikey` header against stored publishable keys +- **Secret key verification** (`auth: 'secret'`) — compares the `apikey` header against stored secret keys See `src/core/utils/timing-safe-equal.ts` for the implementation. @@ -21,18 +21,18 @@ See `src/core/utils/timing-safe-equal.ts` for the implementation. Each auth mode provides a different level of trust: -| Mode | What it verifies | Who the caller is | `supabase` client | `supabaseAdmin` client | -| -------- | ----------------------------------- | ------------------------ | ------------------ | ---------------------- | -| `user` | JWT signature against JWKS | An authenticated user | Row-Level Security | Full access | -| `public` | Publishable API key (timing-safe) | A known client app | Row-Level Security | Full access | -| `secret` | Secret API key (timing-safe) | A trusted server/service | Full access | Full access | -| `always` | Nothing — all requests are accepted | Unknown | Row-Level Security | Full access | +| Mode | What it verifies | Who the caller is | `supabase` client | `supabaseAdmin` client | +| ------------- | ----------------------------------- | ------------------------ | ------------------ | ---------------------- | +| `user` | JWT signature against JWKS | An authenticated user | Row-Level Security | Full access | +| `publishable` | Publishable API key (timing-safe) | A known client app | Row-Level Security | Full access | +| `secret` | Secret API key (timing-safe) | A trusted server/service | Full access | Full access | +| `none` | Nothing — all requests are accepted | Unknown | Row-Level Security | Full access | Key implications: - **`user` mode** verifies the JWT using a local JWKS (JSON Web Key Set). The token must contain a `sub` claim. Verification uses the `jose` library's `jwtVerify` with a local key set — no network calls to an auth server. -- **`public` and `secret` modes** compare the `apikey` header against known keys. The comparison is timing-safe. If you use named keys (`allow: 'secret:automations'`), only that specific key is accepted — this follows the principle of least privilege. -- **`always` mode** performs zero authentication. The handler runs for every request. The `supabaseAdmin` client is still available, so a compromised `always` endpoint with write operations is a security risk. Only use it for truly public endpoints or when you implement your own auth (e.g., webhook signature verification). +- **`publishable` and `secret` modes** compare the `apikey` header against known keys. The comparison is timing-safe. If you use named keys (`auth: 'secret:automations'`), only that specific key is accepted — this follows the principle of least privilege. +- **`none` mode** performs zero authentication. The handler runs for every request. The `supabaseAdmin` client is still available, so a compromised `none` endpoint with write operations is a security risk. Only use it for truly public endpoints or when you implement your own auth (e.g., webhook signature verification). ## Named key isolation @@ -40,10 +40,10 @@ Instead of accepting any valid API key, you can restrict an endpoint to a specif ```ts // Accepts any secret key -withSupabase({ allow: 'secret' }, handler) +withSupabase({ auth: 'secret' }, handler) // Only accepts the "automations" secret key -withSupabase({ allow: 'secret:automations' }, handler) +withSupabase({ auth: 'secret:automations' }, handler) ``` This limits the blast radius if a key is compromised. An attacker with the `web` publishable key cannot access an endpoint that requires `secret:automations`. Named keys also make it easier to rotate or revoke access for a specific consumer without affecting others. @@ -56,11 +56,11 @@ JWT verification in `user` mode works as follows: 2. The token is verified against the JWKS from the `SUPABASE_JWKS` environment variable 3. Verification uses `jose`'s `jwtVerify` with a **local** key set — there are no network calls to a JWKS endpoint 4. The token must contain a `sub` (subject) claim to be considered valid -5. On success, the decoded claims are available as `ctx.userClaims` and `ctx.claims` +5. On success, the decoded claims are available as `ctx.userClaims` and `ctx.jwtClaims` If JWKS is not configured (`SUPABASE_JWKS` is missing or malformed), `user` mode is unavailable and will always reject requests. -**No silent downgrade.** When `user` is combined with other modes (e.g. `allow: ['user', 'public']`), a JWT that is present but fails verification rejects the request with `InvalidCredentialsError` — it does not fall through to the next mode. This prevents a bad token paired with a valid `apikey` (or with `'always'`) from being silently downgraded to a less-privileged auth mode. Requests that simply omit the `Authorization` header still fall through as expected. +**No silent downgrade.** When `user` is combined with other modes (e.g. `auth: ['user', 'publishable']`), a JWT that is present but fails verification rejects the request with `InvalidCredentialsError` — it does not fall through to the next mode. This prevents a bad token paired with a valid `apikey` (or with `'none'`) from being silently downgraded to a less-privileged auth mode. Requests that simply omit the `Authorization` header still fall through as expected. ## CORS handling @@ -79,6 +79,6 @@ The Hono adapter does **not** handle CORS — use Hono's built-in `cors` middlew Credentials are extracted from two standard headers: - `Authorization: Bearer ` → used by `user` mode -- `apikey: ` → used by `public` and `secret` modes +- `apikey: ` → used by `publishable` and `secret` modes Extraction is a separate step from verification (`extractCredentials` vs `verifyCredentials`). This separation means you can inspect raw credentials in custom flows without triggering validation. diff --git a/docs/ssr-frameworks.md b/docs/ssr-frameworks.md index b3b7f21..384fe19 100644 --- a/docs/ssr-frameworks.md +++ b/docs/ssr-frameworks.md @@ -1,141 +1,77 @@ -# SSR Frameworks +# Cookie-based environments (Next.js, SvelteKit, Remix) ## When you need this -In SSR frameworks like Next.js, Nuxt, SvelteKit, and Remix, the user's JWT doesn't arrive in an `Authorization` header — it's stored in session cookies managed by `@supabase/ssr`. The high-level wrappers (`withSupabase`, `createSupabaseContext`) expect a standard `Request` with auth headers, so they don't work directly in SSR contexts. +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. -Instead, use the [core primitives](core-primitives.md) to build a lightweight adapter for your framework. The pattern is the same everywhere — only the cookie-reading part changes. +The recommended pattern is to **compose `@supabase/server` with [`@supabase/ssr`](https://github.com/supabase/ssr)**: -## The pattern +- `@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. -Every SSR adapter follows these steps: +You hand `@supabase/ssr`'s fresh access token to `verifyCredentials`, then build typed clients from the result. -1. **Extract the access token from cookies** (framework-specific) -2. **Bridge environment variables** to the `SupabaseEnv` shape -3. **Resolve JWKS** for JWT verification -4. **Call `verifyCredentials`** with the extracted token -5. **Create clients** with `createContextClient` + `createAdminClient` -6. **Return a `SupabaseContext`** +## How the pieces fit -## Reading Supabase session cookies +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). -`@supabase/ssr` stores the session in cookies using a chunked, base64-encoded format: +## Step 1 — `@supabase/ssr` middleware (refresh-token rotation) -- **Cookie name:** `sb--auth-token` (the project ref is extracted from your Supabase URL) -- **Chunking:** if the session is too large for a single cookie, it's split into `sb--auth-token.0`, `.1`, `.2`, etc. -- **Base64 encoding:** the cookie value may be prefixed with `base64-`, indicating base64url encoding - -To extract the access token: +This middleware is required. It refreshes the access token cookie before any Server Component or Route Handler runs: ```ts -const BASE64_PREFIX = 'base64-' - -function getAccessTokenFromCookies( - getCookie: (name: string) => string | undefined, - supabaseUrl: string, -): string | null { - // Extract project ref from URL: "https://abc123.supabase.co" → "abc123" - const ref = new URL(supabaseUrl).hostname.split('.')[0] - const storageKey = `sb-${ref}-auth-token` - - // Try single cookie first, then chunked - let raw = getCookie(storageKey) ?? null - - if (!raw) { - const chunks: string[] = [] - for (let i = 0; ; i++) { - const chunk = getCookie(`${storageKey}.${i}`) - if (!chunk) break - chunks.push(chunk) - } - if (chunks.length > 0) raw = chunks.join('') - } - - if (!raw) return null - - // Decode base64url if needed - let decoded = raw - if (decoded.startsWith(BASE64_PREFIX)) { - try { - const base64 = decoded - .substring(BASE64_PREFIX.length) - .replace(/-/g, '+') - .replace(/_/g, '/') - decoded = atob(base64) - } catch { - return null - } - } - - // Parse the session JSON and extract access_token - try { - const session = JSON.parse(decoded) - return session.access_token ?? null - } catch { - return null - } -} -``` - -The `getCookie` parameter is a function that reads a cookie by name — its implementation depends on your framework (e.g., `cookies().get(name)?.value` in Next.js, `event.cookies.get(name)` in SvelteKit). - -## Environment variable bridging - -SSR frameworks often use their own naming conventions for environment variables. Map them to a `Partial` that the core primitives expect: - -```ts -import type { SupabaseEnv } from '@supabase/server' +// 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), + ) + }, + }, + }, + ) -function resolveEnvFromFramework(): Partial { - // 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 + // Triggers refresh-token rotation and writes the new cookies via setAll. + await supabase.auth.getUser() - return { - url: url ?? undefined, - publishableKeys: publishableKey ? { default: publishableKey } : {}, - secretKeys: secretKey ? { default: secretKey } : {}, - // JWKS: either set SUPABASE_JWKS env var, or fetch it (see below) - } + return supabaseResponse } -``` -## 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: - -```ts -import type { SupabaseEnv } from '@supabase/server' - -let cachedJwks: SupabaseEnv['jwks'] = null - -async function getJwks(supabaseUrl: string): Promise { - 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 const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], } ``` -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`. +If you skip this middleware, the cookie's access token will eventually expire and `verifyCredentials` will reject the request. -## Complete example: Next.js adapter +## Step 2 — composed adapter -A full adapter for Next.js App Router — works in Server Components, Server Actions, and Route Handlers: +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. ```ts // lib/supabase/context.ts +import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' import { verifyCredentials, @@ -143,55 +79,11 @@ import { createAdminClient, } from '@supabase/server/core' import type { - AllowWithKey, + AuthModeWithKey, SupabaseContext, SupabaseEnv, } from '@supabase/server' -const BASE64_PREFIX = 'base64-' - -function getAccessTokenFromCookies( - cookieStore: Awaited>, - url: string, -): string | null { - const ref = new URL(url).hostname.split('.')[0] - const storageKey = `sb-${ref}-auth-token` - - let raw = cookieStore.get(storageKey)?.value ?? null - - if (!raw) { - const chunks: string[] = [] - for (let i = 0; ; i++) { - const chunk = cookieStore.get(`${storageKey}.${i}`)?.value - if (!chunk) break - chunks.push(chunk) - } - if (chunks.length > 0) raw = chunks.join('') - } - - if (!raw) return null - - let decoded = raw - if (decoded.startsWith(BASE64_PREFIX)) { - try { - const base64 = decoded - .substring(BASE64_PREFIX.length) - .replace(/-/g, '+') - .replace(/_/g, '/') - decoded = atob(base64) - } catch { - return null - } - } - - try { - const session = JSON.parse(decoded) - return session.access_token ?? null - } catch { - return null - } -} - function resolveNextEnv(): Partial { const url = process.env.NEXT_PUBLIC_SUPABASE_URL const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY @@ -219,25 +111,54 @@ async function getJwks(supabaseUrl: string): Promise { } export async function createSupabaseContext( - options: { allow?: AllowWithKey | AllowWithKey[] } = { allow: 'user' }, + options: { auth?: AuthModeWithKey | AuthModeWithKey[] } = { auth: 'user' }, ): Promise< { data: SupabaseContext; error: null } | { data: null; error: Error } > { const nextEnv = resolveNextEnv() - if (!nextEnv.url) { - return { data: null, error: new Error('Missing SUPABASE_URL') } + 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 token = getAccessTokenFromCookies(cookieStore, nextEnv.url) + 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 = { ...nextEnv, jwks } const { data: auth, error } = await verifyCredentials( { token, apikey: null }, - { allow: options.allow ?? 'user', env }, + { auth: options.auth ?? 'user', env }, ) if (error) { @@ -255,14 +176,69 @@ export async function createSupabaseContext( supabase, supabaseAdmin, userClaims: auth!.userClaims, - claims: auth!.claims, - authType: auth!.authType, + 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` that the core primitives expect: + +```ts +import type { SupabaseEnv } from '@supabase/server' + +function resolveEnvFromFramework(): Partial { + // 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: + +```ts +import type { SupabaseEnv } from '@supabase/server' + +let cachedJwks: SupabaseEnv['jwks'] = null + +async function getJwks(supabaseUrl: string): Promise { + 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 @@ -313,18 +289,18 @@ export async function GET() { ```ts // Public endpoint — no auth required -const { data: ctx } = await createSupabaseContext({ allow: 'always' }) +const { data: ctx } = await createSupabaseContext({ auth: 'none' }) // Accept either user JWT or skip auth -const { data: ctx } = await createSupabaseContext({ allow: ['user', 'always'] }) +const { data: ctx } = await createSupabaseContext({ auth: ['user', 'none'] }) ``` ## Adapting for other frameworks -The adapter above is Next.js-specific only in how it reads cookies (`await cookies()` from `next/headers`). To adapt for another framework, replace the cookie-reading logic: +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.get(name)` in `+page.server.ts` or `+server.ts` -- **Nuxt:** `useCookie(name)` in server routes, or `getCookie(event, name)` from `h3` -- **Remix:** `request.headers.get('cookie')` then parse with a cookie library +- **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. diff --git a/docs/typescript-generics.md b/docs/typescript-generics.md index 44f2ee5..c2ed36f 100644 --- a/docs/typescript-generics.md +++ b/docs/typescript-generics.md @@ -21,7 +21,7 @@ import { withSupabase } from '@supabase/server' import type { Database } from './database.types.ts' export default { - fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => { + fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { // ctx.supabase is SupabaseClient // Fully typed: column names, return type, etc. const { data } = await ctx.supabase @@ -40,7 +40,7 @@ import { createSupabaseContext } from '@supabase/server' import type { Database } from './database.types.ts' const { data: ctx, error } = await createSupabaseContext(request, { - allow: 'user', + auth: 'user', }) if (error) { @@ -61,7 +61,7 @@ import { } from '@supabase/server/core' import type { Database } from './database.types.ts' -const { data: auth } = await verifyAuth(request, { allow: 'user' }) +const { data: auth } = await verifyAuth(request, { auth: 'user' }) const supabase = createContextClient({ auth: { token: auth!.token }, @@ -86,7 +86,7 @@ import type { Database } from './database.types.ts' const app = new Hono() -app.use('*', withSupabase({ allow: 'user' })) +app.use('*', withSupabase({ auth: 'user' })) app.get('/todos', async (c) => { const { supabase } = c.var.supabaseContext as SupabaseContext @@ -106,7 +106,7 @@ import type { Database } from './database.types.ts' export default { fetch: withSupabase( { - allow: 'user', + auth: 'user', supabaseOptions: { db: { schema: 'api' } }, }, async (_req, ctx) => { @@ -128,7 +128,7 @@ export default { ```ts withSupabase( { - allow: 'user', + auth: 'user', supabaseOptions: { db: { schema: 'api' }, global: { diff --git a/jsr.json b/jsr.json index aa2c69d..1ef793c 100644 --- a/jsr.json +++ b/jsr.json @@ -7,14 +7,7 @@ "./adapters/hono": "./src/adapters/hono/index.ts" }, "publish": { - "include": [ - "src/**/*.ts", - "README.md", - "LICENSE" - ], - "exclude": [ - "src/**/*.test.ts", - "src/**/*.spec.ts" - ] + "include": ["src/**/*.ts", "README.md", "LICENSE"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] } } diff --git a/skills/supabase-server/SKILL.md b/skills/supabase-server/SKILL.md index a8c1717..eb5c2b8 100644 --- a/skills/supabase-server/SKILL.md +++ b/skills/supabase-server/SKILL.md @@ -1,14 +1,18 @@ --- name: supabase-server -description: Use when writing server-side code with Supabase — Edge Functions, Hono apps, webhook handlers, or any backend that needs Supabase auth and client creation. Trigger whenever the user imports from `@supabase/server`, mentions `supabase/server`, Supabase Edge Functions, or needs server-side auth (JWT verification, API key validation, CORS handling) with Supabase. Also trigger when you see legacy patterns in existing code — `Deno.serve`, `createClient(Deno.env.get('SUPABASE_URL'))`, imports from `esm.sh/@supabase`, `deno.land/std` serve, or usage of `SUPABASE_ANON_KEY` / `SUPABASE_SERVICE_ROLE_KEY` — these indicate code that should be migrated to this package. +description: Use when planning or writing server-side code that uses `@supabase/server` — Edge Functions, Hono apps, webhook handlers, or any backend that creates Supabase clients or validates inbound auth. Trigger **before** writing or modifying any file that imports from `@supabase/server` (or sub-paths like `@supabase/server/core`); calls `withSupabase`, `createSupabaseContext`, `createAdminClient`, `createContextClient`, `verifyAuth`, `verifyCredentials`, or `extractCredentials`; configures an `auth:` mode (`'none'` | `'publishable'` | `'secret'` | `'user'`, or keyed variants like `'secret:*'`); or lives under `supabase/functions/` and authenticates an inbound request. Also trigger during planning — if a plan mentions any of the above, load the skill before drafting code; do not extrapolate `auth:` values or auth modes from neighboring functions. Also trigger when you see legacy patterns to migrate to this package — `Deno.serve`, `createClient(Deno.env.get('SUPABASE_URL'))`, imports from `esm.sh/@supabase` or `deno.land/std`, usage of `SUPABASE_ANON_KEY` / `SUPABASE_SERVICE_ROLE_KEY`, or the deprecated `allow:` config option / removed `'always'` / `'public'` mode values / removed `authType` field. --- # @supabase/server -> **Beta:** This package is under active development. APIs and documentation may change. If you find a bug or have a feature request, please [open an issue](https://github.com/supabase/server/issues) or [submit a PR](https://github.com/supabase/server/blob/main/CONTRIBUTING.md). +> **v1.0 — Public Beta.** First stable release under SemVer: breaking changes only ship as a major bump. The package is still early — expect new adapters, ergonomic improvements, and features to land frequently in minor releases. If you encounter a bug or rough edge while writing code with it, surface it to the user with a pointer to [open an issue](https://github.com/supabase/server/issues). > **This is a brand new package.** There is no information available online yet — no blog posts, no Stack Overflow answers, no tutorials. Do not search the web for usage examples. Rely exclusively on the documentation files listed below and the source code in this repository. +> **The config option is `auth`, not `allow`.** `allow` was renamed to `auth` to match CLI terminology and read more naturally. The legacy `allow` key still works (with a one-time `console.warn`) but is deprecated and will be removed in a future major release. **Always emit `auth` in new code** — e.g. `withSupabase({ auth: 'user' }, ...)`. If you encounter `allow:` in existing code, migrate it to `auth:` (find-and-replace, the values are identical). + +> **Auth mode values: `'none'` (not `'always'`), `'publishable'` (not `'public'`).** The four valid values are `'user'`, `'publishable'`, `'secret'`, `'none'`. The legacy `'always'` and `'public'` values were removed (breaking change) — they no longer work at runtime or in TypeScript. Always emit the new values in code you write, and migrate any legacy references you find: `'always'` → `'none'`, `'public'` → `'publishable'`, `'public:'` → `'publishable:'`. Runtime checks like `ctx.authType === 'public'` must also be updated to `ctx.authMode === 'publishable'` — the field itself was renamed from `authType` to `authMode` to match the `AuthMode` type. + > **Do not use legacy Supabase keys.** The `anon` key and `service_role` key (env vars `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`) are legacy and will be deprecated. Do not use them unless the user explicitly asks. Always use the new API keys: > > | Legacy (avoid) | New (use this) | @@ -16,15 +20,15 @@ description: Use when writing server-side code with Supabase — Edge Functions, > | `SUPABASE_ANON_KEY` | `SUPABASE_PUBLISHABLE_KEY(S)` (`sb_publishable_...`) | > | `SUPABASE_SERVICE_ROLE_KEY` | `SUPABASE_SECRET_KEY(S)` (`sb_secret_...`) | > -> Do not call `createClient(url, anonKey)` directly — use `@supabase/server` auth modes (`allow: 'user'`, `allow: 'secret'`, etc.) which handle key resolution automatically. If migrating existing code, replace `SUPABASE_ANON_KEY` usage with `allow: 'public'` and `SUPABASE_SERVICE_ROLE_KEY` usage with `allow: 'secret'`. +> Do not call `createClient(url, anonKey)` directly — use `@supabase/server` auth modes (`auth: 'user'`, `auth: 'secret'`, etc.) which handle key resolution automatically. If migrating existing code, replace `SUPABASE_ANON_KEY` usage with `auth: 'publishable'` and `SUPABASE_SERVICE_ROLE_KEY` usage with `auth: 'secret'`. Server-side utilities for Supabase. Handles auth, client creation, and context injection so you write business logic, not boilerplate. ## What this package does - Wraps fetch handlers with credential verification, CORS, and pre-configured Supabase clients -- Supports 4 auth modes: `user` (JWT), `public` (publishable key), `secret` (secret key), `always` (none) -- Array syntax (`allow: ['user', 'secret']`) is first-match-wins. A present-but-invalid JWT rejects with `InvalidCredentialsError` — it does not silently downgrade to the next mode. +- Supports 4 auth modes: `user` (JWT), `publishable` (publishable key), `secret` (secret key), `none` (no credentials required) +- Array syntax (`auth: ['user', 'secret']`) is first-match-wins. A present-but-invalid JWT rejects with `InvalidCredentialsError` — it does not silently downgrade to the next mode. - Provides composable core primitives for custom auth flows and framework integration - Includes a Hono adapter for per-route auth @@ -38,14 +42,14 @@ Server-side utilities for Supabase. Handles auth, client creation, and context i ## Quick starts -> **Supabase Edge Functions: disable `verify_jwt` for non-user auth.** By default, Supabase Edge Functions require a valid JWT on every request. If your function uses `allow: 'public'`, `allow: 'secret'`, or `allow: 'always'`, you must disable the platform-level JWT check in `supabase/config.toml`, otherwise the request will be rejected before it reaches your handler: +> **Supabase Edge Functions: disable `verify_jwt` for non-user auth.** By default, Supabase Edge Functions require a valid JWT on every request. If your function uses `auth: 'publishable'`, `auth: 'secret'`, or `auth: 'none'`, you must disable the platform-level JWT check in `supabase/config.toml`, otherwise the request will be rejected before it reaches your handler: > > ```toml > [functions.my-function] > verify_jwt = false > ``` > -> Functions using `allow: 'user'` can leave `verify_jwt` enabled (the default) since callers already provide a valid JWT. +> Functions using `auth: 'user'` can leave `verify_jwt` enabled (the default) since callers already provide a valid JWT. ### Supabase Edge Functions (Deno) @@ -56,7 +60,7 @@ Environment variables are auto-injected by the platform — zero config. **All i import { withSupabase } from 'npm:@supabase/server' export default { - fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => { + fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { const { data } = await ctx.supabase.from('todos').select() return Response.json(data) }), @@ -70,7 +74,7 @@ import { createSupabaseContext } from 'npm:@supabase/server' export default { fetch: async (req: Request) => { const { data: ctx, error } = await createSupabaseContext(req, { - allow: 'user', + auth: 'user', }) if (error) { return Response.json( @@ -92,7 +96,7 @@ Requires `nodejs_compat` compatibility flag in `wrangler.toml`, or pass env over import { withSupabase } from '@supabase/server' export default { - fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => { + fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { const { data } = await ctx.supabase.from('todos').select() return Response.json(data) }), @@ -101,7 +105,7 @@ export default { ### Hono -CORS is not handled by the adapter — use `hono/cors` middleware. See `docs/hono-adapter.md`. +CORS is not handled by the adapter — use `hono/cors` middleware. See `docs/adapters/hono.md`. ```ts // Node.js / Bun @@ -109,7 +113,7 @@ import { Hono } from 'hono' import { withSupabase } from '@supabase/server/adapters/hono' const app = new Hono() -app.use('*', withSupabase({ allow: 'user' })) +app.use('*', withSupabase({ auth: 'user' })) app.get('/todos', async (c) => { const { supabase } = c.var.supabaseContext @@ -126,7 +130,7 @@ import { Hono } from 'npm:hono' import { withSupabase } from 'npm:@supabase/server/adapters/hono' const app = new Hono() -app.use('*', withSupabase({ allow: 'user' })) +app.use('*', withSupabase({ auth: 'user' })) app.get('/todos', async (c) => { const { supabase } = c.var.supabaseContext @@ -137,12 +141,13 @@ app.get('/todos', async (c) => { export default { fetch: app.fetch } ``` -### SSR Frameworks (Next.js, Nuxt, SvelteKit, Remix) +### Cookie-based environments (compose with `@supabase/ssr`) -In SSR frameworks the JWT lives in session cookies, not the `Authorization` header. Use `@supabase/server/core` primitives to build a framework adapter. The pattern: extract token from cookies, call `verifyCredentials`, then `createContextClient`. See `docs/ssr-frameworks.md` for the full adapter pattern. +For Next.js / SvelteKit / Remix, **compose `@supabase/server` with [`@supabase/ssr`](https://github.com/supabase/ssr)** — they are not replacements for each other. `@supabase/ssr` owns cookies and refresh-token rotation (its middleware is required, otherwise the access token cookie goes stale and verification fails). In your Server Component or Route Handler, use `@supabase/ssr`'s `createServerClient` to read the (middleware-refreshed) session, hand the access token to `verifyCredentials` from `@supabase/server/core`, then build the typed clients with `createContextClient` + `createAdminClient`. See `docs/ssr-frameworks.md` for the full adapter pattern. ```ts // Key imports for building the adapter +import { createServerClient } from '@supabase/ssr' import { verifyCredentials, createContextClient, @@ -161,7 +166,7 @@ import { withSupabase } from 'npm:@supabase/server' // Only accept the "automations" named secret key export default { - fetch: withSupabase({ allow: 'secret:automations' }, async (req, ctx) => { + fetch: withSupabase({ auth: 'secret:automations' }, async (req, ctx) => { const body = await req.json() const { data } = await ctx.supabaseAdmin .from('scheduled_tasks') @@ -187,26 +192,26 @@ await fetch('https://.supabase.co/functions/v1/my-function', { }) ``` -Use `allow: 'secret'` to accept any secret key, or `allow: 'secret:name'` to require a specific named key. +Use `auth: 'secret'` to accept any secret key, or `auth: 'secret:name'` to require a specific named key. -## When to use `allow: 'always'` +## When to use `auth: 'none'` -> **`allow: 'always'` disables all authentication.** The handler runs for every request with no credential checks. Only use it when auth is genuinely unnecessary — health checks, public status pages, or endpoints with no sensitive data and no side effects. +> **`auth: 'none'` disables all authentication.** The handler runs for every request with no credential checks. Only use it when auth is genuinely unnecessary — health checks, public status pages, or endpoints with no sensitive data and no side effects. -**Before using `allow: 'always'`, confirm with the user whether the endpoint is truly public.** If not, propose an alternative: +**Before using `auth: 'none'`, confirm with the user whether the endpoint is truly public.** If not, propose an alternative: -- **Another service or cron job calls this function** — use `allow: 'secret'` or `allow: 'secret:'` instead. The caller sends the secret key in the `apikey` header. -- **An external webhook provider calls this function** — use `allow: 'secret'` and have the provider send the secret key, or implement the provider's own signature verification inside the handler. +- **Another service or cron job calls this function** — use `auth: 'secret'` or `auth: 'secret:'` instead. The caller sends the secret key in the `apikey` header. +- **An external webhook provider calls this function** — use `auth: 'secret'` and have the provider send the secret key, or implement the provider's own signature verification inside the handler. -**Never use `allow: 'always'` for endpoints that read or write user data without verifying who the caller is.** +**Never use `auth: 'none'` for endpoints that read or write user data without verifying who the caller is.** -**On `allow: ['user', 'always']`.** A stale or malformed JWT on such an endpoint is rejected with `InvalidCredentialsError` — it is not silently downgraded to anonymous. Callers that might hold a cached/expired token should either omit the `Authorization` header entirely or refresh before calling. If the goal is "anonymous unless a valid user is signed in," this is the correct behavior; if the goal is truly "accept anything," use `allow: 'always'` on its own. +**On `auth: ['user', 'none']`.** A stale or malformed JWT on such an endpoint is rejected with `InvalidCredentialsError` — it is not silently downgraded to anonymous. Callers that might hold a cached/expired token should either omit the `Authorization` header entirely or refresh before calling. If the goal is "anonymous unless a valid user is signed in," this is the correct behavior; if the goal is truly "accept anything," use `auth: 'none'` on its own. ## Edge Function recipes ### Function-to-function calls -One Edge Function can call another using the admin client. The called function uses `allow: 'secret'` and the caller invokes it via `ctx.supabaseAdmin.functions.invoke()`. +One Edge Function can call another using the admin client. The called function uses `auth: 'secret'` and the caller invokes it via `ctx.supabaseAdmin.functions.invoke()`. **Config** (`supabase/config.toml`): @@ -221,7 +226,7 @@ verify_jwt = false # called with secret key, not a user JWT import { withSupabase } from 'npm:@supabase/server' export default { - fetch: withSupabase({ allow: 'secret' }, async (req, ctx) => { + fetch: withSupabase({ auth: 'secret' }, async (req, ctx) => { const { orderId } = await req.json() const { data } = await ctx.supabaseAdmin .from('orders') @@ -240,7 +245,7 @@ export default { import { withSupabase } from 'npm:@supabase/server' export default { - fetch: withSupabase({ allow: 'user' }, async (req, ctx) => { + fetch: withSupabase({ auth: 'user' }, async (req, ctx) => { const { orderId } = await req.json() // Calls process-order with the secret key automatically @@ -291,11 +296,11 @@ select net.http_post( ); ``` -The receiving function uses `allow: 'secret'` (see example above). `pg_net` is asynchronous — the HTTP request is queued and executed in the background. Check `net._http_response` for results. +The receiving function uses `auth: 'secret'` (see example above). `pg_net` is asynchronous — the HTTP request is queued and executed in the background. Check `net._http_response` for results. ### Stripe webhook -External webhook providers like Stripe cannot send your Supabase API keys. Use `allow: 'always'` to skip credential checks, then verify the webhook signature inside the handler. +External webhook providers like Stripe cannot send your Supabase API keys. Use `auth: 'none'` to skip credential checks, then verify the webhook signature inside the handler. **Config** (`supabase/config.toml`): @@ -320,7 +325,7 @@ import Stripe from 'npm:stripe' const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!) export default { - fetch: withSupabase({ allow: 'always' }, async (req, ctx) => { + fetch: withSupabase({ auth: 'none' }, async (req, ctx) => { const body = await req.text() const sig = req.headers.get('stripe-signature')! @@ -390,14 +395,14 @@ Uses the latest API keys, works across runtimes (Deno, Node.js, Cloudflare), and import { withSupabase } from 'npm:@supabase/server' export default { - fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => { + fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { const { data } = await ctx.supabase.from('orders').select('*') return Response.json(data) }), } ``` -The migration mapping: `SUPABASE_ANON_KEY` with manual auth header → `allow: 'user'`, `SUPABASE_ANON_KEY` without auth → `allow: 'public'`. For `SUPABASE_SERVICE_ROLE_KEY`, it depends on intent: if the legacy code validates the incoming key to protect the endpoint (e.g., `req.headers.get('apikey') === serviceRoleKey`), use `allow: 'secret'`. If it only uses the key to create an admin client for elevated DB access, no specific auth mode is needed — `ctx.supabaseAdmin` is always available regardless of auth mode. +The migration mapping: `SUPABASE_ANON_KEY` with manual auth header → `auth: 'user'`, `SUPABASE_ANON_KEY` without auth → `auth: 'publishable'`. For `SUPABASE_SERVICE_ROLE_KEY`, it depends on intent: if the legacy code validates the incoming key to protect the endpoint (e.g., `req.headers.get('apikey') === serviceRoleKey`), use `auth: 'secret'`. If it only uses the key to create an admin client for elevated DB access, no specific auth mode is needed — `ctx.supabaseAdmin` is always available regardless of auth mode. ## Documentation @@ -406,15 +411,17 @@ The full documentation lives in the `docs/` directory of the `@supabase/server` - **If working inside the SDK repo:** `docs/` is at the project root. - **If the package is installed as a dependency:** look in `node_modules/@supabase/server/docs/`. -| Question | Doc file | -| -------------------------------------------------------- | ------------------------------- | -| How do I create a basic endpoint? | `docs/getting-started.md` | -| What auth modes are available? Array syntax? Named keys? | `docs/auth-modes.md` | -| How do I use this with Hono? | `docs/hono-adapter.md` | -| How do I use low-level primitives for custom flows? | `docs/core-primitives.md` | -| How do environment variables work across runtimes? | `docs/environment-variables.md` | -| How do I handle errors? What codes exist? | `docs/error-handling.md` | -| How do I get typed database queries? | `docs/typescript-generics.md` | -| How do I use this in Next.js, Nuxt, SvelteKit, or Remix? | `docs/ssr-frameworks.md` | -| What's the complete API surface? | `docs/api-reference.md` | -| What security decisions does this package make? | `docs/security.md` | +| Question | Doc file | +| ------------------------------------------------------------------- | ------------------------------- | +| How do I create a basic endpoint? | `docs/getting-started.md` | +| What auth modes are available? Array syntax? Named keys? | `docs/auth-modes.md` | +| Which framework adapters exist? How do I contribute one? | `src/adapters/README.md` | +| How do I use this with Hono? | `docs/adapters/hono.md` | +| How do I use this with H3 / Nuxt? | `docs/adapters/h3.md` | +| How do I use low-level primitives for custom flows? | `docs/core-primitives.md` | +| How do environment variables work across runtimes? | `docs/environment-variables.md` | +| How do I handle errors? What codes exist? | `docs/error-handling.md` | +| How do I get typed database queries? | `docs/typescript-generics.md` | +| How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | `docs/ssr-frameworks.md` | +| What's the complete API surface? | `docs/api-reference.md` | +| What security decisions does this package make? | `docs/security.md` | diff --git a/src/adapters/README.md b/src/adapters/README.md new file mode 100644 index 0000000..742ed35 --- /dev/null +++ b/src/adapters/README.md @@ -0,0 +1,38 @@ +# Adapters + +You're in the adapter source folder. Framework adapters wrap `withSupabase` and `createSupabaseContext` for a specific framework's middleware contract — Hono middleware, H3 event handlers, and so on. Implementations live next to this README under `/`; reference docs live at [`docs/adapters/.md`](../../docs/adapters/). + +## Available adapters + +| Framework | Import | Framework version | Docs | +| --------- | -------------------------------- | ----------------- | ---------------------------------------------------- | +| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](../../docs/adapters/hono.md) | +| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](../../docs/adapters/h3.md) | + +The framework version reflects what the adapter is tested against. It must match the corresponding entry in [`package.json#peerDependencies`](../../package.json) — if you bump the peer-dep range, update this table too. + +## Community-maintained + +**Every adapter listed above is community-maintained.** Both Hono and H3 originated as community contributions. Adapters live in this repo and ship with the core package, so users get them with a single `npm install @supabase/server` — no separate package per framework. + +The Supabase team reviews PRs, runs security and regression triage, and ships releases. The original contributor of an adapter is the de-facto domain expert and is expected to be the first responder on framework-version bumps and bug reports for that adapter. + +## Contributing a new adapter + +Before you start, **read [`CONTRIBUTING.md`](../../CONTRIBUTING.md) and agree with it.** That covers the development setup, code style, commit conventions, and PR process. The points below are _additional_ requirements specific to adapter contributions. + +**Code quality bar:** + +- **Tests for every auth mode.** Cover `'user'`, `'publishable'`, `'secret'`, `'none'`, the array form, and the failure paths (missing token, invalid JWT, missing apikey). The Hono adapter's [`hono/middleware.test.ts`](hono/middleware.test.ts) is the canonical reference — your test file should look structurally similar. +- **Strict TypeScript.** No `any`, no `// @ts-ignore`. Public types must be exported from the adapter's `index.ts` so consumers can extend them. +- **No new runtime dependencies** beyond the framework you're adapting. The framework itself goes in `peerDependencies` (and `peerDependenciesMeta` if optional). Don't pull in a wrapper, polyfill, or utility lib just to make the adapter shorter. +- **Match the existing adapter shape.** Export `withSupabase(config, handler)` returning the framework's native middleware/handler type. Use `verifyAuth`, `createContextClient`, and `createAdminClient` from `@supabase/server/core` — never re-implement auth or env handling inside an adapter. +- **Wire up the build outputs.** Add the adapter entry to `package.json#exports`, `jsr.json` (if applicable), and `tsdown.config.ts#entry` so it ships in the published artifact. +- **Docs are required.** Add `docs/adapters/.md` mirroring the structure of [`docs/adapters/hono.md`](../../docs/adapters/hono.md) — at minimum: setup, basic example, per-route auth, CORS note. +- **Update both adapter tables.** Add a row to the table in this `src/adapters/README.md` _and_ the mirror table in the top-level [`README.md`](../../README.md). Keep the framework-version column accurate against `package.json#peerDependencies`. PRs that touch an existing adapter must update the version column if the peer-dep range changed. + +The Supabase team will review the PR against these requirements. Once merged, the adapter ships in the next release as part of `@supabase/server` — no separate package, no extra install for users. As the original contributor, you're expected to be the first responder on framework-version bumps and bug reports for your adapter. + +## Designing an adapter + +The existing adapters at [`hono/middleware.ts`](hono/middleware.ts) and [`h3/middleware.ts`](h3/middleware.ts) (siblings of this README) are the canonical templates. The shape every adapter exposes is `withSupabase(config, handler)` returning a framework-native middleware. Keep all auth logic in `@supabase/server/core` — adapters should only translate request/response shapes between the framework and the core primitives. diff --git a/src/adapters/h3/middleware.test.ts b/src/adapters/h3/middleware.test.ts index 9de0841..00eb53c 100644 --- a/src/adapters/h3/middleware.test.ts +++ b/src/adapters/h3/middleware.test.ts @@ -13,11 +13,11 @@ describe('h3 supabase middleware', () => { it('sets supabase context on successful auth', async () => { const app = new H3() - app.use(withSupabase({ allow: 'always', env })) + app.use(withSupabase({ auth: 'none', env })) app.get('/', (event) => { const ctx = event.context.supabaseContext return { - authType: ctx.authType, + authMode: ctx.authMode, hasSupabase: !!ctx.supabase, hasAdmin: !!ctx.supabaseAdmin, } @@ -26,14 +26,14 @@ describe('h3 supabase middleware', () => { const res = await app.request('/') expect(res.status).toBe(200) const body = await res.json() - expect(body.authType).toBe('always') + expect(body.authMode).toBe('none') expect(body.hasSupabase).toBe(true) expect(body.hasAdmin).toBe(true) }) it('throws HTTPError on auth failure', async () => { const app = new H3() - app.use(withSupabase({ allow: 'user', env })) + app.use(withSupabase({ auth: 'user', env })) app.get('/', () => ({ ok: true })) const res = await app.request('/') @@ -55,7 +55,7 @@ describe('h3 supabase middleware', () => { ) }), ) - app.use(withSupabase({ allow: 'user', env })) + app.use(withSupabase({ auth: 'user', env })) app.get('/', () => ({ ok: true })) const res = await app.request('/') @@ -68,14 +68,14 @@ describe('h3 supabase middleware', () => { it('skips if context is already set by prior middleware', async () => { const app = new H3() - // First middleware sets context with 'always' auth - app.use(withSupabase({ allow: 'always', env })) + // First middleware sets context with 'none' auth + app.use(withSupabase({ auth: 'none', env })) // Second middleware would require 'secret' — but should skip - app.use(withSupabase({ allow: 'secret', env })) + app.use(withSupabase({ auth: 'secret', env })) app.get('/', (event) => { const ctx = event.context.supabaseContext - return { authType: ctx.authType } + return { authMode: ctx.authMode } }) // No apikey header — would fail 'secret' if it ran @@ -83,12 +83,12 @@ describe('h3 supabase middleware', () => { expect(res.status).toBe(200) const body = await res.json() // First middleware's auth type is preserved - expect(body.authType).toBe('always') + expect(body.authMode).toBe('none') }) it('does not add CORS headers', async () => { const app = new H3() - app.use(withSupabase({ allow: 'always', env })) + app.use(withSupabase({ auth: 'none', env })) app.get('/', () => ({ ok: true })) const res = await app.request('/') diff --git a/src/adapters/h3/middleware.ts b/src/adapters/h3/middleware.ts index 93a0469..2aad16e 100644 --- a/src/adapters/h3/middleware.ts +++ b/src/adapters/h3/middleware.ts @@ -19,7 +19,7 @@ import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' * import { withSupabase } from '@supabase/server/adapters/h3' * * const app = new H3() - * app.use(withSupabase({ allow: 'user' })) + * app.use(withSupabase({ auth: 'user' })) * * app.get('/games', async (event) => { * const { supabase } = event.context.supabaseContext @@ -35,7 +35,7 @@ import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' * import { withSupabase } from '@supabase/server/adapters/h3' * * export default defineHandler({ - * middleware: [withSupabase({ allow: 'user' })], + * middleware: [withSupabase({ auth: 'user' })], * handler: async (event) => { * const { supabase } = event.context.supabaseContext * return supabase.from('favorite_games').select() diff --git a/src/adapters/hono/middleware.test.ts b/src/adapters/hono/middleware.test.ts index 4cac946..e37fcdc 100644 --- a/src/adapters/hono/middleware.test.ts +++ b/src/adapters/hono/middleware.test.ts @@ -16,11 +16,11 @@ describe('hono supabase middleware', () => { it('sets supabase context on successful auth', async () => { const app = new Hono() - app.use('*', withSupabase({ allow: 'always', env })) + app.use('*', withSupabase({ auth: 'none', env })) app.get('/', (c) => { const ctx = c.get('supabaseContext') return c.json({ - authType: ctx.authType, + authMode: ctx.authMode, hasSupabase: !!ctx.supabase, hasAdmin: !!ctx.supabaseAdmin, }) @@ -29,14 +29,14 @@ describe('hono supabase middleware', () => { const res = await app.request('/') expect(res.status).toBe(200) const body = await res.json() - expect(body.authType).toBe('always') + expect(body.authMode).toBe('none') expect(body.hasSupabase).toBe(true) expect(body.hasAdmin).toBe(true) }) it('throws HTTPException on auth failure', async () => { const app = new Hono() - app.use('*', withSupabase({ allow: 'user', env })) + app.use('*', withSupabase({ auth: 'user', env })) app.get('/', (c) => c.json({ ok: true })) const res = await app.request('/') @@ -47,7 +47,7 @@ describe('hono supabase middleware', () => { it('exposes AuthError via cause in app.onError', async () => { const app = new Hono() - app.use('*', withSupabase({ allow: 'user', env })) + app.use('*', withSupabase({ auth: 'user', env })) app.get('/', (c) => c.json({ ok: true })) app.onError((err, c) => { const cause = (err as Error).cause as @@ -69,14 +69,14 @@ describe('hono supabase middleware', () => { it('skips if context is already set by prior middleware', async () => { const app = new Hono() - // First middleware sets context with 'always' auth - app.use('*', withSupabase({ allow: 'always', env })) + // First middleware sets context with 'none' auth + app.use('*', withSupabase({ auth: 'none', env })) // Second middleware would require 'secret' — but should skip - app.use('*', withSupabase({ allow: 'secret', env })) + app.use('*', withSupabase({ auth: 'secret', env })) app.get('/', (c) => { const ctx = c.get('supabaseContext') - return c.json({ authType: ctx.authType }) + return c.json({ authMode: ctx.authMode }) }) // No apikey header — would fail 'secret' if it ran @@ -84,12 +84,12 @@ describe('hono supabase middleware', () => { expect(res.status).toBe(200) const body = await res.json() // First middleware's auth type is preserved - expect(body.authType).toBe('always') + expect(body.authMode).toBe('none') }) it('does not add CORS headers', async () => { const app = new Hono() - app.use('*', withSupabase({ allow: 'always', env })) + app.use('*', withSupabase({ auth: 'none', env })) app.get('/', (c) => c.json({ ok: true })) const res = await app.request('/') diff --git a/src/adapters/hono/middleware.ts b/src/adapters/hono/middleware.ts index b5c28ea..33581d4 100644 --- a/src/adapters/hono/middleware.ts +++ b/src/adapters/hono/middleware.ts @@ -20,7 +20,7 @@ import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' * import { withSupabase } from '@supabase/server/adapters/hono' * * const app = new Hono() - * app.use('*', withSupabase({ allow: 'user' })) + * app.use('*', withSupabase({ auth: 'user' })) * * app.get('/profile', async (c) => { * const { supabase } = c.var.supabaseContext @@ -38,8 +38,8 @@ export function withSupabase( Variables: { supabaseContext: SupabaseContext } }>(async (c, next) => { // Skip if a previous middleware already set the context. - // This allows route-level overrides: a route can use withSupabase({ allow: 'secret' }) - // while the app-wide middleware uses withSupabase({ allow: 'user' }), without the + // This enables route-level overrides: a route can use withSupabase({ auth: 'secret' }) + // while the app-wide middleware uses withSupabase({ auth: 'user' }), without the // app-wide one overwriting the stricter context already established. if (c.var.supabaseContext) { await next() diff --git a/src/core/create-context-client.ts b/src/core/create-context-client.ts index a89f704..7f3515b 100644 --- a/src/core/create-context-client.ts +++ b/src/core/create-context-client.ts @@ -18,7 +18,7 @@ import { resolveEnv } from './resolve-env.js' * * @example * ```ts - * const { data: auth } = await verifyAuth(request, { allow: 'user' }) + * const { data: auth } = await verifyAuth(request, { auth: 'user' }) * const supabase = createContextClient({ * auth: { token: auth.token, keyName: auth.keyName }, * }) diff --git a/src/core/utils/deprecation.ts b/src/core/utils/deprecation.ts new file mode 100644 index 0000000..24c41c6 --- /dev/null +++ b/src/core/utils/deprecation.ts @@ -0,0 +1,47 @@ +import type { AuthModeWithKey } from '../../types.js' + +let allowDeprecationWarned = false + +/** + * Emits a one-time deprecation warning when the legacy `allow` option is used + * instead of `auth`. The warning fires at most once per process to avoid + * spamming logs in long-running servers. + * + * @internal + */ +export function warnAllowDeprecated(): void { + if (allowDeprecationWarned) return + allowDeprecationWarned = true + console.warn( + '[@supabase/server] The `allow` option is deprecated and will be removed in a future major release. Use `auth` instead — e.g. `{ auth: "user" }` instead of `{ allow: "user" }`.', + ) +} + +/** + * Resolves the auth mode from `auth` (preferred) or `allow` (deprecated), + * falling back to `"user"` when neither is provided. Emits a one-time + * deprecation warning when `allow` is used without `auth`. + * + * @internal + */ +export function resolveAuthOption(options: { + auth?: AuthModeWithKey | AuthModeWithKey[] + allow?: AuthModeWithKey | AuthModeWithKey[] +}): AuthModeWithKey | AuthModeWithKey[] { + if (options.auth !== undefined) return options.auth + if (options.allow !== undefined) { + warnAllowDeprecated() + return options.allow + } + return 'user' +} + +/** + * Test-only helper to reset the one-shot deprecation warning latch so each + * test can independently observe the warning. + * + * @internal + */ +export function _resetAllowDeprecationWarned(): void { + allowDeprecationWarned = false +} diff --git a/src/core/verify-auth.test.ts b/src/core/verify-auth.test.ts index 044da97..d7065a4 100644 --- a/src/core/verify-auth.test.ts +++ b/src/core/verify-auth.test.ts @@ -14,16 +14,16 @@ describe('verifyAuth', () => { const req = new Request('http://localhost', { headers: { apikey: 'sb_publishable_xyz' }, }) - const result = await verifyAuth(req, { allow: 'public', env }) + const result = await verifyAuth(req, { auth: 'publishable', env }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('public') + expect(result.data!.authMode).toBe('publishable') }) it('fails when credentials do not match', async () => { const req = new Request('http://localhost', { headers: { apikey: 'wrong' }, }) - const result = await verifyAuth(req, { allow: 'public', env }) + const result = await verifyAuth(req, { auth: 'publishable', env }) expect(result.error).not.toBeNull() }) }) diff --git a/src/core/verify-auth.ts b/src/core/verify-auth.ts index 1055355..07d4029 100644 --- a/src/core/verify-auth.ts +++ b/src/core/verify-auth.ts @@ -1,5 +1,5 @@ import type { AuthError } from '../errors.js' -import type { AllowWithKey, AuthResult, SupabaseEnv } from '../types.js' +import type { AuthModeWithKey, AuthResult, SupabaseEnv } from '../types.js' import { extractCredentials } from './extract-credentials.js' import { verifyCredentials } from './verify-credentials.js' @@ -10,9 +10,18 @@ interface VerifyAuthOptions { /** * Auth mode(s) to try. Modes are attempted in order — the first match wins. * - * @see {@link AllowWithKey} for the full syntax including named keys. + * @see {@link AuthModeWithKey} for the full syntax including named keys. + * + * @defaultValue `"user"` + */ + auth?: AuthModeWithKey | AuthModeWithKey[] + + /** + * @deprecated Use {@link VerifyAuthOptions.auth} instead. Kept for backward + * compatibility; will be removed in a future major release. When both are + * provided, `auth` wins. */ - allow: AllowWithKey | AllowWithKey[] + allow?: AuthModeWithKey | AuthModeWithKey[] /** Optional environment overrides (passed through to {@link resolveEnv}). */ env?: Partial @@ -37,7 +46,7 @@ interface VerifyAuthOptions { * import { verifyAuth } from '@supabase/server/core' * * const { data: auth, error } = await verifyAuth(request, { - * allow: 'user', + * auth: 'user', * }) * * if (error) { diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index 9b71675..c5a4410 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -1,8 +1,9 @@ import { exportJWK, generateKeyPair, SignJWT } from 'jose' -import { beforeAll, describe, expect, it } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { Credentials, JsonWebKeySet, SupabaseEnv } from '../types.js' import { verifyCredentials } from './verify-credentials.js' +import { _resetAllowDeprecationWarned } from './utils/deprecation.js' import { InvalidCredentialsError } from '../errors.js' function makeEnv(overrides?: Partial): Partial { @@ -16,45 +17,45 @@ function makeEnv(overrides?: Partial): Partial { } describe('verifyCredentials', () => { - describe('always mode', () => { + describe('none mode', () => { it('succeeds with no credentials and keyName is null', async () => { const creds: Credentials = { token: null, apikey: null } const result = await verifyCredentials(creds, { - allow: 'always', + auth: 'none', env: makeEnv(), }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('always') + expect(result.data!.authMode).toBe('none') expect(result.data!.keyName).toBeNull() }) }) - describe('public mode', () => { + describe('publishable mode', () => { it('succeeds with valid publishable key and returns default keyName', async () => { const creds: Credentials = { token: null, apikey: 'sb_publishable_xyz', } const result = await verifyCredentials(creds, { - allow: 'public', + auth: 'publishable', env: makeEnv(), }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('public') + expect(result.data!.authMode).toBe('publishable') expect(result.data!.keyName).toBe('default') }) it('fails with invalid key', async () => { const creds: Credentials = { token: null, apikey: 'wrong_key' } const result = await verifyCredentials(creds, { - allow: 'public', + auth: 'publishable', env: makeEnv(), }) expect(result.error).not.toBeNull() expect(result.error!.code).toBe(InvalidCredentialsError) }) - it('only matches default key when bare public is used', async () => { + it('only matches default key when bare publishable is used', async () => { const env = makeEnv({ publishableKeys: { default: 'sb_publishable_default', @@ -63,7 +64,7 @@ describe('verifyCredentials', () => { }) const creds: Credentials = { token: null, apikey: 'sb_publishable_web' } const result = await verifyCredentials(creds, { - allow: 'public', + auth: 'publishable', env, }) expect(result.error).not.toBeNull() @@ -79,7 +80,7 @@ describe('verifyCredentials', () => { }) const creds: Credentials = { token: null, apikey: 'sb_publishable_web' } const result = await verifyCredentials(creds, { - allow: 'public:web', + auth: 'publishable:web', env, }) expect(result.error).toBeNull() @@ -98,7 +99,7 @@ describe('verifyCredentials', () => { apikey: 'sb_publishable_mobile', } const result = await verifyCredentials(creds, { - allow: 'public:web', + auth: 'publishable:web', env, }) expect(result.error).not.toBeNull() @@ -114,7 +115,7 @@ describe('verifyCredentials', () => { }) const creds: Credentials = { token: null, apikey: 'sb_publishable_web' } const result = await verifyCredentials(creds, { - allow: 'secret:web', + auth: 'secret:web', env, }) expect(result.error).not.toBeNull() @@ -133,11 +134,11 @@ describe('verifyCredentials', () => { apikey: 'sb_publishable_mobile', } const result = await verifyCredentials(creds, { - allow: 'public:*', + auth: 'publishable:*', env, }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('public') + expect(result.data!.authMode).toBe('publishable') }) it('wildcard returns correct keyName for non-first key', async () => { @@ -153,7 +154,7 @@ describe('verifyCredentials', () => { apikey: 'sb_publishable_mobile', } const result = await verifyCredentials(creds, { - allow: 'public:*', + auth: 'publishable:*', env, }) expect(result.error).toBeNull() @@ -165,18 +166,18 @@ describe('verifyCredentials', () => { it('succeeds with valid secret key and returns default keyName', async () => { const creds: Credentials = { token: null, apikey: 'sb_secret_xyz' } const result = await verifyCredentials(creds, { - allow: 'secret', + auth: 'secret', env: makeEnv(), }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('secret') + expect(result.data!.authMode).toBe('secret') expect(result.data!.keyName).toBe('default') }) it('fails with invalid secret key', async () => { const creds: Credentials = { token: null, apikey: 'wrong_secret' } const result = await verifyCredentials(creds, { - allow: 'secret', + auth: 'secret', env: makeEnv(), }) expect(result.error).not.toBeNull() @@ -189,7 +190,7 @@ describe('verifyCredentials', () => { }) const creds: Credentials = { token: null, apikey: 'sb_secret_web' } const result = await verifyCredentials(creds, { - allow: 'secret', + auth: 'secret', env, }) expect(result.error).not.toBeNull() @@ -202,7 +203,7 @@ describe('verifyCredentials', () => { }) const creds: Credentials = { token: null, apikey: 'sb_secret_web' } const result = await verifyCredentials(creds, { - allow: 'secret:web', + auth: 'secret:web', env, }) expect(result.error).toBeNull() @@ -215,7 +216,7 @@ describe('verifyCredentials', () => { }) const creds: Credentials = { token: null, apikey: 'sb_secret_mobile' } const result = await verifyCredentials(creds, { - allow: 'secret:web', + auth: 'secret:web', env, }) expect(result.error).not.toBeNull() @@ -228,7 +229,7 @@ describe('verifyCredentials', () => { }) const creds: Credentials = { token: null, apikey: 'sb_secret_web' } const result = await verifyCredentials(creds, { - allow: 'public:web', + auth: 'publishable:web', env, }) expect(result.error).not.toBeNull() @@ -241,11 +242,11 @@ describe('verifyCredentials', () => { }) const creds: Credentials = { token: null, apikey: 'sb_secret_mobile' } const result = await verifyCredentials(creds, { - allow: 'secret:*', + auth: 'secret:*', env, }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('secret') + expect(result.data!.authMode).toBe('secret') }) it('wildcard returns correct keyName for non-first key', async () => { @@ -258,7 +259,7 @@ describe('verifyCredentials', () => { }) const creds: Credentials = { token: null, apikey: 'sb_secret_mobile' } const result = await verifyCredentials(creds, { - allow: 'secret:*', + auth: 'secret:*', env, }) expect(result.error).toBeNull() @@ -291,22 +292,22 @@ describe('verifyCredentials', () => { it('succeeds with valid JWT', async () => { const creds: Credentials = { token: validToken, apikey: null } const result = await verifyCredentials(creds, { - allow: 'user', + auth: 'user', env: makeEnv({ jwks }), }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('user') + expect(result.data!.authMode).toBe('user') expect(result.data!.keyName).toBeNull() expect(result.data!.userClaims!.id).toBe('user-123') expect(result.data!.userClaims!.email).toBe('test@example.com') - expect(result.data!.claims!.sub).toBe('user-123') + expect(result.data!.jwtClaims!.sub).toBe('user-123') expect(result.data!.token).toBe(validToken) }) it('fails with invalid JWT', async () => { const creds: Credentials = { token: 'invalid.jwt.token', apikey: null } const result = await verifyCredentials(creds, { - allow: 'user', + auth: 'user', env: makeEnv({ jwks }), }) expect(result.error).not.toBeNull() @@ -316,7 +317,7 @@ describe('verifyCredentials', () => { it('fails with no token', async () => { const creds: Credentials = { token: null, apikey: null } const result = await verifyCredentials(creds, { - allow: 'user', + auth: 'user', env: makeEnv({ jwks }), }) expect(result.error).not.toBeNull() @@ -338,7 +339,7 @@ describe('verifyCredentials', () => { const creds: Credentials = { token: expiredToken, apikey: null } const result = await verifyCredentials(creds, { - allow: 'user', + auth: 'user', env: makeEnv({ jwks: expiredJwks }), }) expect(result.error).not.toBeNull() @@ -346,18 +347,18 @@ describe('verifyCredentials', () => { }) }) - describe('parseAllowMode edge cases', () => { + describe('parseAuthMode edge cases', () => { it('treats trailing colon as bare mode (default key)', async () => { const creds: Credentials = { token: null, apikey: 'sb_publishable_xyz', } const result = await verifyCredentials(creds, { - allow: 'public:' as 'public', + auth: 'publishable:' as 'publishable', env: makeEnv(), }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('public') + expect(result.data!.authMode).toBe('publishable') }) it('treats multiple colons as part of key name', async () => { @@ -369,11 +370,11 @@ describe('verifyCredentials', () => { apikey: 'sb_publishable_colon', } const result = await verifyCredentials(creds, { - allow: 'public:key:extra' as 'public', + auth: 'publishable:key:extra' as 'publishable', env, }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('public') + expect(result.data!.authMode).toBe('publishable') }) it('fails wildcard with empty key object', async () => { @@ -383,7 +384,7 @@ describe('verifyCredentials', () => { apikey: 'sb_publishable_xyz', } const result = await verifyCredentials(creds, { - allow: 'public:*' as 'public', + auth: 'publishable:*' as 'publishable', env, }) expect(result.error).not.toBeNull() @@ -391,29 +392,29 @@ describe('verifyCredentials', () => { }) }) - describe('array allow (first match wins)', () => { + describe('array auth (first match wins)', () => { it('matches second mode when first fails and returns its keyName', async () => { const creds: Credentials = { token: null, apikey: 'sb_publishable_xyz', } const result = await verifyCredentials(creds, { - allow: ['secret', 'public'], + auth: ['secret', 'publishable'], env: makeEnv(), }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('public') + expect(result.data!.authMode).toBe('publishable') expect(result.data!.keyName).toBe('default') }) it('matches first mode when it succeeds', async () => { const creds: Credentials = { token: null, apikey: null } const result = await verifyCredentials(creds, { - allow: ['always', 'public'], + auth: ['none', 'publishable'], env: makeEnv(), }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('always') + expect(result.data!.authMode).toBe('none') }) }) @@ -428,17 +429,17 @@ describe('verifyCredentials', () => { jwks = { keys: [publicJwk] } }) - it('rejects invalid JWT instead of falling through to always mode', async () => { + it('rejects invalid JWT instead of falling through to none mode', async () => { const creds: Credentials = { token: 'garbage.jwt.token', apikey: null } const result = await verifyCredentials(creds, { - allow: ['user', 'always'], + auth: ['user', 'none'], env: makeEnv({ jwks }), }) expect(result.error).not.toBeNull() expect(result.error!.code).toBe(InvalidCredentialsError) }) - it('rejects expired JWT instead of falling through to always mode', async () => { + it('rejects expired JWT instead of falling through to none mode', async () => { const { privateKey, publicKey } = await generateKeyPair('RS256') const publicJwk = await exportJWK(publicKey) publicJwk.alg = 'RS256' @@ -453,7 +454,7 @@ describe('verifyCredentials', () => { const creds: Credentials = { token: expiredToken, apikey: null } const result = await verifyCredentials(creds, { - allow: ['user', 'always'], + auth: ['user', 'none'], env: makeEnv({ jwks: expiredJwks }), }) expect(result.error).not.toBeNull() @@ -463,20 +464,20 @@ describe('verifyCredentials', () => { it('falls through to always when no token is present', async () => { const creds: Credentials = { token: null, apikey: null } const result = await verifyCredentials(creds, { - allow: ['user', 'always'], + auth: ['user', 'none'], env: makeEnv({ jwks }), }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('always') + expect(result.data!.authMode).toBe('none') }) - it('rejects invalid JWT even when public mode follows', async () => { + it('rejects invalid JWT even when publishable mode follows', async () => { const creds: Credentials = { token: 'garbage.jwt.token', apikey: 'sb_publishable_xyz', } const result = await verifyCredentials(creds, { - allow: ['user', 'public'], + auth: ['user', 'publishable'], env: makeEnv({ jwks }), }) expect(result.error).not.toBeNull() @@ -489,7 +490,7 @@ describe('verifyCredentials', () => { apikey: 'sb_secret_xyz', } const result = await verifyCredentials(creds, { - allow: ['user', 'secret'], + auth: ['user', 'secret'], env: makeEnv({ jwks }), }) expect(result.error).not.toBeNull() @@ -511,11 +512,85 @@ describe('verifyCredentials', () => { const creds: Credentials = { token: noSubToken, apikey: null } const result = await verifyCredentials(creds, { - allow: 'user', + auth: 'user', env: makeEnv({ jwks: noSubJwks }), }) expect(result.error).not.toBeNull() expect(result.error!.code).toBe(InvalidCredentialsError) }) }) + + describe('allow → auth deprecation', () => { + beforeEach(() => { + _resetAllowDeprecationWarned() + }) + + it('still accepts the deprecated `allow` option', async () => { + const creds: Credentials = { token: null, apikey: null } + const result = await verifyCredentials(creds, { + allow: 'none', + env: makeEnv(), + }) + expect(result.error).toBeNull() + expect(result.data!.authMode).toBe('none') + }) + + it('emits a deprecation warning when `allow` is used', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const creds: Credentials = { token: null, apikey: null } + await verifyCredentials(creds, { + allow: 'none', + env: makeEnv(), + }) + expect(warn).toHaveBeenCalledTimes(1) + const message = warn.mock.calls[0]![0] as string + expect(message).toContain('@supabase/server') + expect(message).toContain('`allow`') + expect(message).toContain('`auth`') + warn.mockRestore() + }) + + it('only warns once across multiple calls', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const creds: Credentials = { token: null, apikey: null } + await verifyCredentials(creds, { allow: 'none', env: makeEnv() }) + await verifyCredentials(creds, { allow: 'none', env: makeEnv() }) + await verifyCredentials(creds, { allow: 'none', env: makeEnv() }) + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) + + it('does not warn when `auth` is used', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const creds: Credentials = { token: null, apikey: null } + await verifyCredentials(creds, { auth: 'none', env: makeEnv() }) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('prefers `auth` over `allow` when both are provided', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const creds: Credentials = { token: null, apikey: 'sb_secret_xyz' } + const result = await verifyCredentials(creds, { + // `auth` should win and the secret key should match. + auth: 'secret', + // `allow` would have rejected the secret key (publishable mode). + allow: 'publishable', + env: makeEnv(), + }) + expect(result.error).toBeNull() + expect(result.data!.authMode).toBe('secret') + // No warning since `auth` is the operative option. + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('defaults to `user` when neither `auth` nor `allow` is provided', async () => { + const creds: Credentials = { token: null, apikey: null } + const result = await verifyCredentials(creds, { env: makeEnv() }) + // No token, no apikey, default mode is `user` → fails with invalid credentials. + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + }) }) diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index c82ff14..c91f07e 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -2,14 +2,15 @@ import { createLocalJWKSet, jwtVerify } from 'jose' import { AuthError, Errors, InvalidCredentialsError } from '../errors.js' import type { - Allow, - AllowWithKey, + AuthMode, + AuthModeWithKey, AuthResult, Credentials, JWTClaims, SupabaseEnv, UserClaims, } from '../types.js' +import { resolveAuthOption } from './utils/deprecation.js' import { timingSafeEqual } from './utils/timing-safe-equal.js' import { resolveEnv } from './resolve-env.js' @@ -20,40 +21,49 @@ interface VerifyCredentialsOptions { /** * Auth mode(s) to try. Modes are attempted in order — the first match wins. * - * @see {@link AllowWithKey} for the full syntax including named keys. + * @see {@link AuthModeWithKey} for the full syntax including named keys. + * + * @defaultValue `"user"` + */ + auth?: AuthModeWithKey | AuthModeWithKey[] + + /** + * @deprecated Use {@link VerifyCredentialsOptions.auth} instead. Kept for + * backward compatibility; will be removed in a future major release. When + * both are provided, `auth` wins. */ - allow: AllowWithKey | AllowWithKey[] + allow?: AuthModeWithKey | AuthModeWithKey[] /** Optional environment overrides (passed through to {@link resolveEnv}). */ env?: Partial } /** - * Parses an {@link AllowWithKey} string into its base mode and optional key name. + * Parses an {@link AuthModeWithKey} string into its base mode and optional key name. * * @example * ``` - * parseAllowMode('user') → { base: 'user', keyName: null } - * parseAllowMode('public:web') → { base: 'public', keyName: 'web' } - * parseAllowMode('secret:*') → { base: 'secret', keyName: '*' } + * parseAuthMode('user') → { base: 'user', keyName: null } + * parseAuthMode('publishable:web') → { base: 'publishable', keyName: 'web' } + * parseAuthMode('secret:*') → { base: 'secret', keyName: '*' } * ``` * * @internal */ -function parseAllowMode(mode: AllowWithKey): { - base: Allow +function parseAuthMode(mode: AuthModeWithKey): { + base: AuthMode keyName: string | null } { if ( - mode === 'always' || - mode === 'public' || + mode === 'none' || + mode === 'publishable' || mode === 'secret' || mode === 'user' ) { return { base: mode, keyName: null } } const colonIndex = mode.indexOf(':') - const base = mode.slice(0, colonIndex) as Allow + const base = mode.slice(0, colonIndex) as AuthMode const keyName = mode.slice(colonIndex + 1) if (!keyName) return { base, keyName: null } return { base, keyName } @@ -63,13 +73,13 @@ function parseAllowMode(mode: AllowWithKey): { * Converts raw {@link JWTClaims} (snake_case) to a normalized {@link UserClaims} (camelCase). * @internal */ -function claimsToUserClaims(claims: JWTClaims): UserClaims { +function jwtClaimsToUserClaims(jwtClaims: JWTClaims): UserClaims { return { - id: claims.sub, - role: claims.role, - email: claims.email, - appMetadata: claims.app_metadata, - userMetadata: claims.user_metadata, + id: jwtClaims.sub, + role: jwtClaims.role, + email: jwtClaims.email, + appMetadata: jwtClaims.app_metadata, + userMetadata: jwtClaims.user_metadata, } } @@ -86,23 +96,23 @@ const INVALID = Symbol('invalid') * @internal */ async function tryMode( - mode: AllowWithKey, + mode: AuthModeWithKey, credentials: Credentials, env: SupabaseEnv, ): Promise { - const { base, keyName } = parseAllowMode(mode) + const { base, keyName } = parseAuthMode(mode) switch (base) { - case 'always': + case 'none': return { - authType: 'always', + authMode: 'none', token: null, userClaims: null, - claims: null, + jwtClaims: null, keyName: null, } - case 'public': { + case 'publishable': { if (!credentials.apikey) return null const keys = env.publishableKeys @@ -110,10 +120,10 @@ async function tryMode( for (const [name, value] of Object.entries(keys)) { if (await timingSafeEqual(credentials.apikey, value)) { return { - authType: 'public', + authMode: 'publishable', token: null, userClaims: null, - claims: null, + jwtClaims: null, keyName: name, } } @@ -123,10 +133,10 @@ async function tryMode( const value = keys[name] if (value && (await timingSafeEqual(credentials.apikey, value))) { return { - authType: 'public', + authMode: 'publishable', token: null, userClaims: null, - claims: null, + jwtClaims: null, keyName: name, } } @@ -142,10 +152,10 @@ async function tryMode( for (const [name, value] of Object.entries(keys)) { if (await timingSafeEqual(credentials.apikey, value)) { return { - authType: 'secret', + authMode: 'secret', token: null, userClaims: null, - claims: null, + jwtClaims: null, keyName: name, } } @@ -155,10 +165,10 @@ async function tryMode( const value = keys[name] if (value && (await timingSafeEqual(credentials.apikey, value))) { return { - authType: 'secret', + authMode: 'secret', token: null, userClaims: null, - claims: null, + jwtClaims: null, keyName: name, } } @@ -175,12 +185,12 @@ async function tryMode( if (typeof payload.sub !== 'string') { return INVALID } - const claims = payload as unknown as JWTClaims + const jwtClaims = payload as unknown as JWTClaims return { - authType: 'user', + authMode: 'user', token: credentials.token, - userClaims: claimsToUserClaims(claims), - claims, + userClaims: jwtClaimsToUserClaims(jwtClaims), + jwtClaims, keyName: null, } } catch { @@ -210,7 +220,7 @@ async function tryMode( * ```ts * const credentials = extractCredentials(request) * const { data: auth, error } = await verifyCredentials(credentials, { - * allow: ['user', 'public'], + * auth: ['user', 'publishable'], * }) * if (error) { * return Response.json({ message: error.message }, { status: error.status }) @@ -231,7 +241,8 @@ export async function verifyCredentials( } } - const modes = Array.isArray(options.allow) ? options.allow : [options.allow] + const resolved = resolveAuthOption(options) + const modes = Array.isArray(resolved) ? resolved : [resolved] for (const mode of modes) { const result = await tryMode(mode, credentials, env) diff --git a/src/create-supabase-context.test.ts b/src/create-supabase-context.test.ts index 361fc02..632c1b0 100644 --- a/src/create-supabase-context.test.ts +++ b/src/create-supabase-context.test.ts @@ -17,7 +17,7 @@ describe('createSupabaseContext', () => { it('returns context with clients on successful auth', async () => { const req = new Request('http://localhost') const result = await createSupabaseContext(req, { - allow: 'always', + auth: 'none', env: baseEnv, }) @@ -25,25 +25,25 @@ describe('createSupabaseContext', () => { expect(result.data).not.toBeNull() expect(result.data!.supabase).toBeDefined() expect(result.data!.supabaseAdmin).toBeDefined() - expect(result.data!.authType).toBe('always') - expect(result.data!.authKeyName).toBeNull() + expect(result.data!.authMode).toBe('none') + expect(result.data!.authKeyName).toBeUndefined() }) it('returns user and claims as null for non-user auth', async () => { const req = new Request('http://localhost') const result = await createSupabaseContext(req, { - allow: 'always', + auth: 'none', env: baseEnv, }) expect(result.data!.userClaims).toBeNull() - expect(result.data!.claims).toBeNull() + expect(result.data!.jwtClaims).toBeNull() }) it('returns error when auth fails', async () => { const req = new Request('http://localhost') const result = await createSupabaseContext(req, { - allow: 'user', + auth: 'user', env: baseEnv, }) @@ -53,7 +53,7 @@ describe('createSupabaseContext', () => { expect(result.error!.code).toBeDefined() }) - it('defaults to allow: user when no options provided', async () => { + it('defaults to auth: user when no options provided', async () => { const req = new Request('http://localhost') const result = await createSupabaseContext(req, { env: baseEnv }) @@ -62,28 +62,28 @@ describe('createSupabaseContext', () => { expect(result.error!.status).toBe(401) }) - it('accepts public key auth', async () => { + it('accepts publishable key auth', async () => { const req = new Request('http://localhost', { headers: { apikey: 'sb_publishable_xyz' }, }) const result = await createSupabaseContext(req, { - allow: 'public', + auth: 'publishable', env: baseEnv, }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('public') + expect(result.data!.authMode).toBe('publishable') expect(result.data!.authKeyName).toBe('default') expect(result.data!.supabase).toBeDefined() expect(result.data!.supabaseAdmin).toBeDefined() }) - it('accepts public named key auth', async () => { + it('accepts publishable named key auth', async () => { const req = new Request('http://localhost', { headers: { apikey: 'sb_publishable_web' }, }) const result = await createSupabaseContext(req, { - allow: 'public:web', + auth: 'publishable:web', env: { ...baseEnv, publishableKeys: { @@ -93,7 +93,7 @@ describe('createSupabaseContext', () => { }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('public') + expect(result.data!.authMode).toBe('publishable') expect(result.data!.authKeyName).toBe('web') expect(result.data!.supabase).toBeDefined() expect(result.data!.supabaseAdmin).toBeDefined() @@ -104,12 +104,12 @@ describe('createSupabaseContext', () => { headers: { apikey: 'sb_secret_xyz' }, }) const result = await createSupabaseContext(req, { - allow: 'secret', + auth: 'secret', env: baseEnv, }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('secret') + expect(result.data!.authMode).toBe('secret') expect(result.data!.authKeyName).toBe('default') }) @@ -118,7 +118,7 @@ describe('createSupabaseContext', () => { headers: { apikey: 'sb_secret_web' }, }) const result = await createSupabaseContext(req, { - allow: 'secret:web', + auth: 'secret:web', env: { ...baseEnv, secretKeys: { @@ -128,7 +128,7 @@ describe('createSupabaseContext', () => { }) expect(result.error).toBeNull() - expect(result.data!.authType).toBe('secret') + expect(result.data!.authMode).toBe('secret') expect(result.data!.authKeyName).toBe('web') expect(result.data!.supabase).toBeDefined() expect(result.data!.supabaseAdmin).toBeDefined() @@ -139,7 +139,7 @@ describe('createSupabaseContext', () => { headers: { apikey: 'wrong_key' }, }) const result = await createSupabaseContext(req, { - allow: 'secret', + auth: 'secret', env: baseEnv, }) @@ -150,7 +150,7 @@ describe('createSupabaseContext', () => { it('returns error when client creation fails due to missing keys', async () => { const req = new Request('http://localhost') const result = await createSupabaseContext(req, { - allow: 'always', + auth: 'none', env: { url: 'https://test.supabase.co', publishableKeys: {}, @@ -171,7 +171,7 @@ describe('createSupabaseContext', () => { it('passes supabaseOptions through to clients', async () => { const req = new Request('http://localhost') const result = await createSupabaseContext(req, { - allow: 'always', + auth: 'none', env: baseEnv, supabaseOptions: { db: { schema: 'api' } }, }) diff --git a/src/create-supabase-context.ts b/src/create-supabase-context.ts index 3a2b8c7..1ff6ef4 100644 --- a/src/create-supabase-context.ts +++ b/src/create-supabase-context.ts @@ -22,7 +22,7 @@ import { verifyAuth } from './core/verify-auth.js' * * @example * ```ts - * const { data: ctx, error } = await createSupabaseContext(request, { allow: 'user' }) + * const { data: ctx, error } = await createSupabaseContext(request, { auth: 'user' }) * if (error) { * return Response.json({ message: error.message }, { status: error.status }) * } @@ -36,10 +36,9 @@ export async function createSupabaseContext( | { data: SupabaseContext; error: null } | { data: null; error: AuthError } > { - const allow = options?.allow ?? 'user' - const { data: auth, error } = await verifyAuth(request, { - allow, + auth: options?.auth, + allow: options?.allow, env: options?.env, }) if (error) { @@ -52,13 +51,14 @@ export async function createSupabaseContext( supabaseOptions: options?.supabaseOptions, } - const publicKeyName = auth.authType === 'public' ? auth.keyName : undefined + const publishableKeyName = + auth.authMode === 'publishable' ? auth.keyName : undefined const supabase = createContextClient({ - auth: { token: auth.token, keyName: publicKeyName }, + auth: { token: auth.token, keyName: publishableKeyName }, ...config, }) - const adminKeyName = auth.authType === 'secret' ? auth.keyName : undefined + const adminKeyName = auth.authMode === 'secret' ? auth.keyName : undefined const supabaseAdmin = createAdminClient({ auth: { keyName: adminKeyName }, ...config, @@ -69,9 +69,9 @@ export async function createSupabaseContext( supabase, supabaseAdmin, userClaims: auth.userClaims, - claims: auth.claims, - authType: auth.authType, - authKeyName: auth.keyName, + jwtClaims: auth.jwtClaims, + authMode: auth.authMode, + authKeyName: auth.keyName ?? undefined, }, error: null, } diff --git a/src/errors.ts b/src/errors.ts index ddf41a9..d729e2a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -95,7 +95,7 @@ const EnvErrorMap = { * ```ts * import { AuthError, createSupabaseContext } from '@supabase/server' * - * const { data: ctx, error } = await createSupabaseContext(request, { allow: 'user' }) + * const { data: ctx, error } = await createSupabaseContext(request, { auth: 'user' }) * if (error) { * // error is an AuthError * return Response.json( diff --git a/src/index.ts b/src/index.ts index 3c5c352..675dec3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ export { createSupabaseContext } from './create-supabase-context.js' export type { Allow, AllowWithKey, + AuthMode, + AuthModeWithKey, AuthResult, ClientAuth, CreateAdminClientOptions, diff --git a/src/types.ts b/src/types.ts index 7393d9e..1b8371e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,45 +6,58 @@ import type { /** * Authentication mode that determines what credentials a request must provide. * - * - `"always"` — No credentials required. Every request is accepted. - * - `"public"` — Requires a valid publishable key in the `apikey` header. + * - `"none"` — No credentials required. Every request is accepted. + * - `"publishable"` — Requires a valid publishable key in the `apikey` header. * - `"secret"` — Requires a valid secret key in the `apikey` header (timing-safe comparison). * - `"user"` — Requires a valid JWT in the `Authorization: Bearer ` header. * * @example * ```ts * // Single mode - * withSupabase({ allow: 'user' }, handler) + * withSupabase({ auth: 'user' }, handler) * * // Multiple modes — the first match wins. * // A mode is tried only when its credential is present; a JWT that is * // present but fails verification rejects immediately rather than falling * // through to the next mode. - * withSupabase({ allow: ['user', 'public'] }, handler) + * withSupabase({ auth: ['user', 'publishable'] }, handler) * ``` */ -export type Allow = 'always' | 'public' | 'secret' | 'user' +export type AuthMode = 'none' | 'publishable' | 'secret' | 'user' + +/** + * @deprecated Use {@link AuthMode} instead. Will be removed in a future major release. + */ +export type Allow = AuthMode /** * Extended auth mode that supports targeting a specific named key. * - * Use the colon syntax (`"public:web_app"`) to require a specific named key + * Use the colon syntax (`"publishable:web_app"`) to require a specific named key * from the `SUPABASE_PUBLISHABLE_KEYS` or `SUPABASE_SECRET_KEYS` JSON object. - * Use `"public:*"` or `"secret:*"` to accept any key in the set. + * Use `"publishable:*"` or `"secret:*"` to accept any key in the set. * * @example * ```ts * // Accept only the "mobile" publishable key - * withSupabase({ allow: 'public:mobile' }, handler) + * withSupabase({ auth: 'publishable:mobile' }, handler) * * // Accept any secret key - * withSupabase({ allow: 'secret:*' }, handler) + * withSupabase({ auth: 'secret:*' }, handler) * * // Mix named keys with other modes - * withSupabase({ allow: ['user', 'public:web_app'] }, handler) + * withSupabase({ auth: ['user', 'publishable:web_app'] }, handler) * ``` */ -export type AllowWithKey = Allow | `public:${string}` | `secret:${string}` +export type AuthModeWithKey = + | AuthMode + | `publishable:${string}` + | `secret:${string}` + +/** + * @deprecated Use {@link AuthModeWithKey} instead. Will be removed in a future major release. + */ +export type AllowWithKey = AuthModeWithKey /** * Resolved Supabase environment configuration. @@ -115,7 +128,7 @@ export interface Credentials { */ export interface AuthResult { /** The auth mode that was successfully matched. */ - authType: Allow + authMode: AuthMode /** The verified JWT, or `null` for non-user auth modes. */ token: string | null @@ -124,7 +137,7 @@ export interface AuthResult { userClaims: UserClaims | null /** Raw JWT payload, or `null` when no JWT is present. */ - claims: JWTClaims | null + jwtClaims: JWTClaims | null /** Name of the matched key (e.g. `"default"`, `"mobile"`), or `null` for `"user"` / `"always"` modes. */ keyName?: string | null @@ -201,16 +214,16 @@ export interface UserClaims { * @example * ```ts * // Require authenticated users, auto-CORS enabled (default) - * const config: WithSupabaseConfig = { allow: 'user' } + * const config: WithSupabaseConfig = { auth: 'user' } * * // Accept users or service-to-service calls, custom CORS headers * const config: WithSupabaseConfig = { - * allow: ['user', 'secret'], + * auth: ['user', 'secret'], * cors: { 'Access-Control-Allow-Origin': 'https://myapp.com' }, * } * * // No auth required, CORS disabled - * const config: WithSupabaseConfig = { allow: 'always', cors: false } + * const config: WithSupabaseConfig = { auth: 'none', cors: false } * ``` */ export interface WithSupabaseConfig { @@ -221,7 +234,14 @@ export interface WithSupabaseConfig { * * @defaultValue `"user"` */ - allow?: AllowWithKey | AllowWithKey[] + auth?: AuthModeWithKey | AuthModeWithKey[] + + /** + * @deprecated Use {@link WithSupabaseConfig.auth} instead. The `allow` option + * is kept for backward compatibility and will be removed in a future major release. + * When both `auth` and `allow` are provided, `auth` takes precedence. + */ + allow?: AuthModeWithKey | AuthModeWithKey[] /** * Override auto-detected environment variables. Useful for testing @@ -252,7 +272,7 @@ export interface WithSupabaseConfig { * @example * ```ts * withSupabase({ - * allow: 'user', + * auth: 'user', * supabaseOptions: { db: { schema: 'api' } }, * }, handler) * ``` @@ -314,11 +334,14 @@ export interface SupabaseContext { userClaims: UserClaims | null /** Raw JWT payload. `null` for non-user auth modes. */ - claims: JWTClaims | null + jwtClaims: JWTClaims | null /** The auth mode that was used for this request. */ - authType: Allow + authMode: AuthMode - /** The auth key name of the API key that was used for this request. */ - authKeyName?: string | null + /** + * The auth key name of the API key that was used for this request. + * Omitted for `'user'` and `'none'` modes, which don't match a named key. + */ + authKeyName?: string } diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 1e7d7d8..8eec024 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' import { withSupabase } from './with-supabase.js' const baseEnv = { @@ -11,7 +12,7 @@ const baseEnv = { describe('withSupabase', () => { it('handles OPTIONS preflight with CORS', async () => { - const handler = withSupabase({ allow: 'always', env: baseEnv }, async () => + const handler = withSupabase({ auth: 'none', env: baseEnv }, async () => Response.json({ ok: true }), ) @@ -23,7 +24,7 @@ describe('withSupabase', () => { it('skips OPTIONS handling when cors is false', async () => { const handler = withSupabase( - { allow: 'always', env: baseEnv, cors: false }, + { auth: 'none', env: baseEnv, cors: false }, async () => Response.json({ ok: true }), ) @@ -35,10 +36,10 @@ describe('withSupabase', () => { it('calls handler with context on successful auth', async () => { const handler = withSupabase( - { allow: 'always', env: baseEnv }, + { auth: 'none', env: baseEnv }, async (_req, ctx) => { return Response.json({ - authType: ctx.authType, + authMode: ctx.authMode, hasSupabase: !!ctx.supabase, hasAdmin: !!ctx.supabaseAdmin, }) @@ -48,13 +49,13 @@ describe('withSupabase', () => { const req = new Request('http://localhost') const res = await handler(req) const body = await res.json() - expect(body.authType).toBe('always') + expect(body.authMode).toBe('none') expect(body.hasSupabase).toBe(true) expect(body.hasAdmin).toBe(true) }) it('returns error response on auth failure', async () => { - const handler = withSupabase({ allow: 'user', env: baseEnv }, async () => + const handler = withSupabase({ auth: 'user', env: baseEnv }, async () => Response.json({ ok: true }), ) @@ -67,7 +68,7 @@ describe('withSupabase', () => { }) it('adds CORS headers to success response', async () => { - const handler = withSupabase({ allow: 'always', env: baseEnv }, async () => + const handler = withSupabase({ auth: 'none', env: baseEnv }, async () => Response.json({ ok: true }), ) @@ -77,7 +78,7 @@ describe('withSupabase', () => { }) it('adds CORS headers to error response', async () => { - const handler = withSupabase({ allow: 'user', env: baseEnv }, async () => + const handler = withSupabase({ auth: 'user', env: baseEnv }, async () => Response.json({ ok: true }), ) @@ -88,7 +89,7 @@ describe('withSupabase', () => { it('does not add CORS headers when cors is false', async () => { const handler = withSupabase( - { allow: 'always', env: baseEnv, cors: false }, + { auth: 'none', env: baseEnv, cors: false }, async () => Response.json({ ok: true }), ) @@ -96,4 +97,37 @@ describe('withSupabase', () => { const res = await handler(req) expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull() }) + + describe('allow → auth deprecation', () => { + beforeEach(() => { + _resetAllowDeprecationWarned() + }) + + it('still works with the deprecated `allow` option', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const handler = withSupabase( + { allow: 'none', env: baseEnv }, + async (_req, ctx) => Response.json({ authMode: ctx.authMode }), + ) + + const req = new Request('http://localhost') + const res = await handler(req) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.authMode).toBe('none') + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('does not warn when `auth` is used', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const handler = withSupabase({ auth: 'none', env: baseEnv }, async () => + Response.json({ ok: true }), + ) + const req = new Request('http://localhost') + await handler(req) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + }) }) diff --git a/src/with-supabase.ts b/src/with-supabase.ts index 54a7aab..b155184 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -18,7 +18,7 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js' * import { withSupabase } from '@supabase/server' * * export default { - * fetch: withSupabase({ allow: 'user' }, async (req, ctx) => { + * fetch: withSupabase({ auth: 'user' }, async (req, ctx) => { * const { data } = await ctx.supabase.rpc('get_my_profile') * return Response.json(data) * }),