From 18dd0479c7b77544512cc978c967d87eb2c2122f Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 26 Aug 2026 16:36:46 +0300 Subject: [PATCH 1/3] feat: add withRequiredClaims user-mode auth gate --- docs/api-reference.md | 51 ++++- docs/postgres.md | 2 +- package.json | 10 + src/middleware/claims/index.ts | 13 +- src/middleware/required-claims/index.test.ts | 220 +++++++++++++++++++ src/middleware/required-claims/index.ts | 117 ++++++++++ src/with-supabase.ts | 6 +- tsdown.config.ts | 1 + 8 files changed, 400 insertions(+), 20 deletions(-) create mode 100644 src/middleware/required-claims/index.test.ts create mode 100644 src/middleware/required-claims/index.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index b9f1ee3..ff3e07a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -187,17 +187,58 @@ Behavior: - Token present but invalid: short-circuits with a 401 and `{ message, code: 'INVALID_CREDENTIALS' }`. - Token present but no JWKS configured: short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }`. Verification is required; the middleware has no decode-only mode. -`withClaims` is not an auth gate. It never rejects a request that has no token, so `[withClaims(), withSupabaseClient()]` is not the composable form of `withSupabase({ auth: 'user' })` and accepts anonymous callers. To require an authenticated caller, gate with `withSupabase({ auth: 'user' })` and compose further middleware through its `middleware` option. A host that takes an entries array can wrap it as the sole entry: +`withClaims` is not an auth gate. It never rejects a request that has no token, so `[withClaims(), withSupabaseClient()]` is not the composable form of `withSupabase({ auth: 'user' })` and accepts anonymous callers. To require an authenticated caller, compose `withRequiredClaims` (`@supabase/server/middleware/required-claims`) instead. The two entries share the `jwtClaims` key, so a pipeline picks "claims if present" or "claims required"; composing both is a compile-time conflict. + +### WithClaimsConfig ```ts -const entry = (h: (req: Request, ctx: object) => Promise) => - withSupabase({ auth: 'user', cors: 'disabled' }, h) +interface WithClaimsConfig { + jwks?: JSONWebKeySet | URL +} ``` -### WithClaimsConfig +Defaults to `SUPABASE_JWKS` (inline JSON) or `SUPABASE_JWKS_URL` (https endpoint) from the environment. + +--- + +## @supabase/server/middleware/required-claims + +### withRequiredClaims ```ts -interface WithClaimsConfig { +const withRequiredClaims: Middleware< + 'jwtClaims', + WithRequiredClaimsConfig | void, + Record, + JWTClaims +> +``` + +The user-mode auth gate. Verifies the caller's Bearer token against the project JWKS and contributes **non-null** `ctx.jwtClaims`. This is the same verification core `withSupabase` uses for its `user` auth mode. + +Behavior: + +- No `Authorization: Bearer` token, or an `sb_*` API key in that position: short-circuits with a 401 and `{ message, code: 'INVALID_CREDENTIALS' }`. The handler never runs. +- Token present but invalid: the same 401. +- Token present but no JWKS configured: short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }`. Verification is required; the middleware has no decode-only mode. + +`withRequiredClaims` is the required-caller counterpart to `withClaims`: "claims required" rather than "claims if present". The two share the `jwtClaims` key, so composing both in one pipeline is a compile-time conflict. + +Because the contribution is non-null, gated handlers read `ctx.jwtClaims` directly, and entries declaring a `jwtClaims` prerequisite, such as `withPostgresClient`, compose with no further verification: + +```ts +pipeline([withRequiredClaims(), withPostgresClient()], async (req, ctx) => { + const rows = await ctx.postgres.query`select id, title from posts` + return Response.json({ rows, caller: ctx.jwtClaims.sub }) +}) +``` + +Inside `withSupabase` the context already carries verified `jwtClaims`, so composing the gate through the `middleware` option is a compile-time conflict. Use `withSupabase({ auth: 'user' })` to gate that path. + +### WithRequiredClaimsConfig + +```ts +interface WithRequiredClaimsConfig { jwks?: JSONWebKeySet | URL } ``` diff --git a/docs/postgres.md b/docs/postgres.md index 18650cb..272fa82 100644 --- a/docs/postgres.md +++ b/docs/postgres.md @@ -158,7 +158,7 @@ Order matters. `withPostgresClient` before `withClaims` is a compile-time error: middleware-prereq: key 'jwtClaims' is not yet on the context (check ordering) ``` -`withClaims` is not an auth gate. It contributes claims when a token is present, and `null` when one is not. The standalone pipeline above therefore also serves anonymous callers, whose queries run as `anon`. To require an authenticated caller, use the `withSupabase` form: `auth: 'user'` rejects token-less requests with a 401 before the handler runs. +`withClaims` is not an auth gate. It contributes claims when a token is present, and `null` when one is not. The standalone pipeline above therefore also serves anonymous callers, whose queries run as `anon`. To require an authenticated caller, swap in [`withRequiredClaims`](../src/middleware/required-claims/index.ts): it rejects token-less requests with a 401 before the handler runs and contributes non-null `jwtClaims`, so the handler reads `ctx.jwtClaims.sub` directly. Inside `withSupabase`, `auth: 'user'` provides the same gate. ## Table grants diff --git a/package.json b/package.json index 4f2e43a..b6fa897 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,16 @@ "default": "./dist/middleware/claims/index.cjs" } }, + "./middleware/required-claims": { + "import": { + "types": "./dist/middleware/required-claims/index.d.mts", + "default": "./dist/middleware/required-claims/index.mjs" + }, + "require": { + "types": "./dist/middleware/required-claims/index.d.cts", + "default": "./dist/middleware/required-claims/index.cjs" + } + }, "./oauth-protected-resource": { "import": { "types": "./dist/oauth-protected-resource/index.d.mts", diff --git a/src/middleware/claims/index.ts b/src/middleware/claims/index.ts index 7b315a1..07a3299 100644 --- a/src/middleware/claims/index.ts +++ b/src/middleware/claims/index.ts @@ -45,15 +45,10 @@ export interface WithClaimsConfig { * token. A pipeline like `[withClaims(), withSupabaseClient()]` accepts * anonymous callers and is not the composable form of * `withSupabase({ auth: 'user' })`, which rejects token-less requests with - * a 401. To require an authenticated caller, gate with - * `withSupabase({ auth: 'user' })` and compose further middleware through - * its `middleware` option. A host that takes an entries array can wrap it - * as the sole entry: - * - * ```ts - * const entry = (h: (req: Request, ctx: object) => Promise) => - * withSupabase({ auth: 'user', cors: 'disabled' }, h) - * ``` + * a 401. To require an authenticated caller, compose `withRequiredClaims` + * from `@supabase/server/middleware/required-claims` instead. The two entries + * share the `jwtClaims` key, so a pipeline picks "claims if present" or + * "claims required"; composing both is a compile-time conflict. * * @example Standalone pipeline * ```ts diff --git a/src/middleware/required-claims/index.test.ts b/src/middleware/required-claims/index.test.ts new file mode 100644 index 0000000..c6e516b --- /dev/null +++ b/src/middleware/required-claims/index.test.ts @@ -0,0 +1,220 @@ +import { pipeline } from '@supabase/middleware' +import { exportJWK, generateKeyPair, generateSecret, SignJWT } from 'jose' +import { + afterEach, + beforeAll, + describe, + expect, + expectTypeOf, + it, + vi, +} from 'vitest' + +import type { JSONWebKeySet } from 'jose' + +import { EnvGenericError, InvalidCredentialsError } from '../../errors.js' +import { withSupabase } from '../../with-supabase.js' +import { withClaims } from '../claims/index.js' +import { withPostgresClient } from '../postgres/index.js' +import { withRequiredClaims } from './index.js' + +import type { JWTClaims } from '../../types.js' + +describe('withRequiredClaims', () => { + let jwks: JSONWebKeySet + let rsToken: string + let hsToken: string + let foreignToken: string + + beforeAll(async () => { + // Asymmetric JWK + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + publicJwk.kid = 'asymmetric-key-id' + + // Symmetric Shared Secret JWK + const jwtSecret = await generateSecret('HS256', { extractable: true }) + const symmetricJwk = await exportJWK(jwtSecret) + symmetricJwk.alg = 'HS256' + symmetricJwk.kid = 'symmetric-shared-secret-key-id' + + jwks = { keys: [publicJwk, symmetricJwk] } + + const signWith = ( + key: CryptoKey | Uint8Array, + alg: string, + kid: string, + ) => + new SignJWT({ sub: 'user-123', role: 'authenticated' }) + .setProtectedHeader({ alg, kid }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(key) + + rsToken = await signWith(privateKey, 'RS256', publicJwk.kid!) + hsToken = await signWith(jwtSecret, 'HS256', symmetricJwk.kid!) + + // Signed by a key that is NOT in the JWKS — verification must fail. + const { privateKey: foreignKey } = await generateKeyPair('RS256') + foreignToken = await signWith(foreignKey, 'RS256', publicJwk.kid!) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + function requestWithToken(token?: string): Request { + return new Request('http://localhost', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + } + + it('contributes JWKS-verified claims for a valid token', async () => { + for (const token of [() => rsToken, () => hsToken]) { + let seen: unknown + const handler = withRequiredClaims({ jwks }, async (_req, ctx) => { + seen = ctx.jwtClaims + return Response.json({ ok: true }) + }) + + const res = await handler(requestWithToken(token())) + expect(res.status).toBe(200) + expect(seen).toMatchObject({ sub: 'user-123', role: 'authenticated' }) + } + }) + + it('short-circuits 401 when no Authorization header is present', async () => { + let ran = false + const handler = withRequiredClaims({ jwks }, async () => { + ran = true + return Response.json({ ok: true }) + }) + + const res = await handler(requestWithToken()) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe(InvalidCredentialsError) + expect(ran).toBe(false) + }) + + it('short-circuits 401 for an sb_* apikey in the Authorization header', async () => { + let ran = false + const handler = withRequiredClaims({ jwks }, async () => { + ran = true + return Response.json({ ok: true }) + }) + + const apikeys = [ + 'sb_publishable_xyz', + 'sb_secret_xyz', + 'sb_temp_xyz', + 'sb_something', + ] + + for (const apikey of apikeys) { + const res = await handler(requestWithToken(apikey)) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe(InvalidCredentialsError) + expect(ran).toBe(false) + } + }) + + it('short-circuits 401 for a token signed by an unknown key', async () => { + const handler = withRequiredClaims({ jwks }, async () => + Response.json({ ok: true }), + ) + + const res = await handler(requestWithToken(foreignToken)) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe(InvalidCredentialsError) + }) + + it('short-circuits 401 for a malformed token', async () => { + const handler = withRequiredClaims({ jwks }, async () => + Response.json({ ok: true }), + ) + + const res = await handler(requestWithToken('not-a-jwt')) + expect(res.status).toBe(401) + }) + + it('short-circuits 500 when a token is present but no JWKS is configured', async () => { + vi.stubEnv('SUPABASE_JWKS', '') + vi.stubEnv('SUPABASE_JWKS_URL', '') + const handler = withRequiredClaims(async () => Response.json({ ok: true })) + + const res = await handler(requestWithToken(rsToken)) + expect(res.status).toBe(500) + const body = await res.json() + expect(body.code).toBe(EnvGenericError) + expect(body.message).toContain('JWKS') + }) + + it('short-circuits 401 when neither a token nor a JWKS is present', async () => { + // Missing credentials are the caller's problem and are reported before + // missing configuration: the JWKS is never resolved for a request that + // carries nothing to verify. + vi.stubEnv('SUPABASE_JWKS', '') + vi.stubEnv('SUPABASE_JWKS_URL', '') + const handler = withRequiredClaims(async () => Response.json({ ok: true })) + + const res = await handler(requestWithToken()) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe(InvalidCredentialsError) + }) +}) + +describe('withRequiredClaims composition (type-level)', () => { + const baseEnv = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + } + + it('satisfies withPostgresClient and the handler sees non-null claims', () => { + const _handler = pipeline( + [withRequiredClaims(), withPostgresClient()], + async (_req, ctx) => { + expectTypeOf(ctx.jwtClaims).toEqualTypeOf() + expectTypeOf(ctx.postgres).not.toBeAny() + return Response.json({ ok: true }) + }, + ) + void _handler + }) + + it('composing with withClaims is a compile-time conflict (gate first)', () => { + const _bad = pipeline( + [withRequiredClaims(), withClaims()], + // @ts-expect-error — Conflict<'jwtClaims'>: both entries contribute the key + async () => Response.json({ ok: true }), + ) + void _bad + }) + + it('composing with withClaims is a compile-time conflict (withClaims first)', () => { + const _bad = pipeline( + [withClaims(), withRequiredClaims()], + // @ts-expect-error — Conflict<'jwtClaims'>: both entries contribute the key + async () => Response.json({ ok: true }), + ) + void _bad + }) + + it('gating inside withSupabase is a compile-time conflict', () => { + // withSupabase already verifies credentials and seeds jwtClaims before + // the middleware array runs, so the gate is redundant there. + // @ts-expect-error — Conflict<'jwtClaims'>: key already on the context + const _bad = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withRequiredClaims()] }, + async () => Response.json({ ok: true }), + ) + void _bad + }) +}) diff --git a/src/middleware/required-claims/index.ts b/src/middleware/required-claims/index.ts new file mode 100644 index 0000000..2e1c058 --- /dev/null +++ b/src/middleware/required-claims/index.ts @@ -0,0 +1,117 @@ +import { defineMiddleware } from '@supabase/middleware' +import type { Middleware } from '@supabase/middleware' +import type { JSONWebKeySet } from 'jose' + +import { extractCredentials } from '../../core/extract-credentials.js' +import { resolveJwks } from '../../core/resolve-env.js' +import { verifyUserJwt } from '../../core/verify-user-jwt.js' +import { EnvGenericError, InvalidCredentialsError } from '../../errors.js' +import type { JWTClaims } from '../../types.js' + +/** + * Configuration for {@link withRequiredClaims}. + * + * @category Middleware + */ +export interface WithRequiredClaimsConfig { + /** + * JWKS source used to verify tokens: an inline key set or a remote JWKS + * URL. Defaults to `SUPABASE_JWKS` (inline JSON) or `SUPABASE_JWKS_URL` + * (https endpoint) from the environment. + */ + jwks?: JSONWebKeySet | URL +} + +/** + * The user-mode auth gate: requires a valid user JWT and contributes + * **non-null** `ctx.jwtClaims`. Verification runs against the project JWKS, + * the same core `withSupabase` uses for its `user` auth mode. + * + * This is the required-caller counterpart to `withClaims`, which contributes + * claims when a token is present and lets token-less requests proceed as + * anonymous. A pipeline picks one or the other, "claims required" or "claims + * if present"; composing both is a compile-time conflict on the `jwtClaims` + * key. + * + * Behavior: + * - No `Authorization: Bearer` token (or an `sb_*` API key in that position, + * which is an API key rather than a user JWT) → short-circuits with a + * 401 JSON response (`{ message, code: 'INVALID_CREDENTIALS' }`, matching + * `withSupabase`'s error shape). The handler never runs. + * - Token present but invalid → the same 401. + * - Token present but no JWKS configured → short-circuits with a 500; + * verification is not optional and there is no decode-only mode. + * + * Because the contribution is non-null, gated handlers read `ctx.jwtClaims` + * directly, with no `?.sub ?? 'anon'` fallbacks. Downstream entries declaring + * a `jwtClaims` prerequisite, such as `withPostgresClient`, compose with no + * further verification. + * + * Inside `withSupabase` the context already carries verified `jwtClaims`, so + * this gate is unnecessary there and composing it through the `middleware` + * option is a compile-time conflict. Use `withSupabase({ auth: 'user' })` to + * gate that path. + * + * @example Gated standalone pipeline + * ```ts + * import { pipeline } from '@supabase/middleware' + * import { withRequiredClaims } from '@supabase/server/middleware/required-claims' + * import { withPostgresClient } from '@supabase/server/middleware/postgres' + * + * export default { + * fetch: pipeline([withRequiredClaims(), withPostgresClient()], async (req, ctx) => { + * const rows = await ctx.postgres.query`select id, title from posts` + * return Response.json({ rows, caller: ctx.jwtClaims.sub }) + * }), + * } + * ``` + * + * @category Middleware + */ +export const withRequiredClaims: Middleware< + 'jwtClaims', + WithRequiredClaimsConfig | void, + Record, + JWTClaims +> = defineMiddleware< + 'jwtClaims', + WithRequiredClaimsConfig | void, + Record, + JWTClaims +>({ + key: 'jwtClaims', + run: (config) => async (req) => { + const { token } = extractCredentials(req) + // `sb_*` secrets ride the Authorization header alongside the apikey + // header — they are API keys, not user JWTs, so they cannot pass a gate + // that requires verified user claims. + if (!token || token.startsWith('sb_')) { + return Response.json( + { message: 'Invalid credentials', code: InvalidCredentialsError }, + { status: 401 }, + ) + } + + const jwks = config?.jwks ?? resolveJwks() + if (!jwks) { + return Response.json( + { + message: + 'A JWKS source is required to verify claims. Set SUPABASE_JWKS or SUPABASE_JWKS_URL, or pass `jwks` to withRequiredClaims.', + code: EnvGenericError, + }, + { status: 500 }, + ) + } + + const verified = await verifyUserJwt(token, jwks) + if (!verified) { + return Response.json( + { message: 'Invalid credentials', code: InvalidCredentialsError }, + { status: 401 }, + ) + } + + return { jwtClaims: verified.jwtClaims } + }, +}) diff --git a/src/with-supabase.ts b/src/with-supabase.ts index 6841033..2eaa814 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -9,11 +9,7 @@ import type { WithSupabaseConfig, } from './types.js' import { isContext, seedContext } from '@supabase/middleware' -import type { - BaseContext, - Entry, - ValidateEntries, -} from '@supabase/middleware' +import type { BaseContext, Entry, ValidateEntries } from '@supabase/middleware' type AnyEntry = Entry // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/tsdown.config.ts b/tsdown.config.ts index 6bc16d9..6d2b164 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ 'src/middleware/postgres/index.ts', 'src/middleware/postgres-admin/index.ts', 'src/middleware/claims/index.ts', + 'src/middleware/required-claims/index.ts', 'src/middleware/client/index.ts', 'src/middleware/admin-client/index.ts', 'src/oauth-protected-resource/index.ts', From 825e79abd00fb49e2a8d5baee65610cb87e2a3f8 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 26 Aug 2026 17:50:08 +0300 Subject: [PATCH 2/3] chore: add export to readme --- README.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c697756..1c6d9ff 100644 --- a/README.md +++ b/README.md @@ -521,21 +521,22 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like ## Exports -| Export | What's in it | -| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `@supabase/server` | `withSupabase`, `createSupabaseContext` | -| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` | -| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) | -| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) | -| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) | -| `@supabase/server/adapters/nestjs` | `withSupabase` (NestJS guard), `SupabaseCtx` (param decorator) | -| `@supabase/server/middleware/client` | `withSupabaseClient` (RLS-scoped `ctx.supabase` client) | -| `@supabase/server/middleware/admin-client` | `withSupabaseAdminClient` (`ctx.supabaseAdmin`, bypasses RLS) | -| `@supabase/server/middleware/claims` | `withClaims` (JWKS-verified `ctx.jwtClaims`) | -| `@supabase/server/middleware/postgres` | `withPostgresClient` (RLS-scoped `ctx.postgres` client) | -| `@supabase/server/middleware/postgres-admin` | `withPostgresAdminClient` (`ctx.postgresAdmin`, bypasses RLS) | -| `@supabase/server/oauth-protected-resource` | `withOAuthProtectedResource`, `fromSupabaseUrl`, `resourceMetadataResponse`, `unauthorizedResponse` | -| `@supabase/server/peer/supabase-js` | Re-exported `supabase-js` types (`SupabaseClient`, `PostgrestError`, …) | +| Export | What's in it | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `@supabase/server` | `withSupabase`, `createSupabaseContext` | +| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` | +| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) | +| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) | +| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) | +| `@supabase/server/adapters/nestjs` | `withSupabase` (NestJS guard), `SupabaseCtx` (param decorator) | +| `@supabase/server/middleware/client` | `withSupabaseClient` (RLS-scoped `ctx.supabase` client) | +| `@supabase/server/middleware/admin-client` | `withSupabaseAdminClient` (`ctx.supabaseAdmin`, bypasses RLS) | +| `@supabase/server/middleware/claims` | `withClaims` (JWKS-verified `ctx.jwtClaims`) | +| `@supabase/server/middleware/required-claims` | `withRequiredClaims` (user-mode auth gate, non-null `ctx.jwtClaims`) | +| `@supabase/server/middleware/postgres` | `withPostgresClient` (RLS-scoped `ctx.postgres` client) | +| `@supabase/server/middleware/postgres-admin` | `withPostgresAdminClient` (`ctx.postgresAdmin`, bypasses RLS) | +| `@supabase/server/oauth-protected-resource` | `withOAuthProtectedResource`, `fromSupabaseUrl`, `resourceMetadataResponse`, `unauthorizedResponse` | +| `@supabase/server/peer/supabase-js` | Re-exported `supabase-js` types (`SupabaseClient`, `PostgrestError`, …) | ## Documentation From 4a88731421597b1df95c0fed0bcc9785e3b8c208 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 27 Aug 2026 16:12:43 +0300 Subject: [PATCH 3/3] fix: add required-claims jsr export, note cors and sole-entry recipe --- docs/api-reference.md | 9 +++++++++ jsr.json | 1 + src/middleware/required-claims/index.ts | 6 ++++++ 3 files changed, 16 insertions(+) diff --git a/docs/api-reference.md b/docs/api-reference.md index ff3e07a..58c0303 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -233,8 +233,17 @@ pipeline([withRequiredClaims(), withPostgresClient()], async (req, ctx) => { }) ``` +The gate's 401 and 500 short-circuits carry no CORS headers, and a bare pipeline answers no `OPTIONS` preflight. For browser callers, compose `withCors` (`@supabase/middleware/cors`) ahead of the gate: it answers preflight before the gate runs and stamps `Access-Control-*` headers on the gate's short-circuit responses. + Inside `withSupabase` the context already carries verified `jwtClaims`, so composing the gate through the `middleware` option is a compile-time conflict. Use `withSupabase({ auth: 'user' })` to gate that path. +The gate contributes `jwtClaims` and nothing else. A handler that needs the full `SupabaseContext` behind an auth gate (for example `ctx.userClaims` or `ctx.authMode`, which no composable entry contributes) uses `withSupabase({ auth: 'user' })` directly. A host that takes an entries array can wrap it as the sole entry. `cors: 'disabled'` leaves CORS handling to the host: + +```ts +const entry = (h: (req: Request, ctx: object) => Promise) => + withSupabase({ auth: 'user', cors: 'disabled' }, h) +``` + ### WithRequiredClaimsConfig ```ts diff --git a/jsr.json b/jsr.json index 14db0ed..4d276a2 100644 --- a/jsr.json +++ b/jsr.json @@ -14,6 +14,7 @@ "./middleware/postgres": "./src/middleware/postgres/index.ts", "./middleware/postgres-admin": "./src/middleware/postgres-admin/index.ts", "./middleware/claims": "./src/middleware/claims/index.ts", + "./middleware/required-claims": "./src/middleware/required-claims/index.ts", "./oauth-protected-resource": "./src/oauth-protected-resource/index.ts" }, "publish": { diff --git a/src/middleware/required-claims/index.ts b/src/middleware/required-claims/index.ts index 2e1c058..553ee02 100644 --- a/src/middleware/required-claims/index.ts +++ b/src/middleware/required-claims/index.ts @@ -47,6 +47,12 @@ export interface WithRequiredClaimsConfig { * a `jwtClaims` prerequisite, such as `withPostgresClient`, compose with no * further verification. * + * The 401 and 500 short-circuits carry no CORS headers, and a bare pipeline + * answers no `OPTIONS` preflight. For browser callers, compose `withCors` + * (`@supabase/middleware/cors`) ahead of the gate: it answers preflight before + * the gate runs and stamps `Access-Control-*` headers on the short-circuit + * responses. + * * Inside `withSupabase` the context already carries verified `jwtClaims`, so * this gate is unnecessary there and composing it through the `middleware` * option is a compile-time conflict. Use `withSupabase({ auth: 'user' })` to