diff --git a/README.md b/README.md index 2666f80..4132a71 100644 --- a/README.md +++ b/README.md @@ -193,16 +193,16 @@ await fetch(refreshEndpoint, { ## Auth Modes -| Mode | Credential | Use case | -| ------------------ | --------------------- | --------------------------------------------------- | -| `"user"` (default) | Valid JWT | Authenticated user endpoints | -| `"publishable"` | Valid publishable key | Client-facing, key-validated endpoints | -| `"secret"` | Valid secret key | Server-to-server, internal calls | -| `"none"` | None | Open endpoints, wrappers that handle their own auth | +| Mode | Credential | Use case | +| ------------------ | ------------------------------- | --------------------------------------------------- | +| `"user"` (default) | Valid JWT | Authenticated user endpoints | +| `"publishable"` | Valid `default` publishable key | Client-facing, key-validated endpoints | +| `"secret"` | Valid `default` secret key | Server-to-server, internal calls | +| `"none"` | None | Open endpoints, wrappers that handle their own auth | -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). +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). -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`. +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`. Bare `auth: "secret"` (or `"publishable"`) matches only the `default` key; use the wildcard `auth: "secret:*"` to accept any key in the set. See [`docs/auth-modes.md`](docs/auth-modes.md). > **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`: > diff --git a/docs/api-reference.md b/docs/api-reference.md index ca7eb87..ed7105f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -180,7 +180,7 @@ type AuthMode = 'none' | 'publishable' | 'secret' | 'user' type AuthModeWithKey = AuthMode | `publishable:${string}` | `secret:${string}` ``` -Extended auth mode with named key support. Examples: `'publishable:web'`, `'secret:*'`, `'secret:internal'`. +Extended auth mode with named key support. Examples: `'publishable:web'`, `'secret:*'`, `'secret:internal'`. The bare form (`'publishable'` / `'secret'`) matches only the `default` key; `:*` accepts any key in the set. ### Allow / AllowWithKey (deprecated aliases) diff --git a/docs/auth-modes.md b/docs/auth-modes.md index 3c9c834..4c819d8 100644 --- a/docs/auth-modes.md +++ b/docs/auth-modes.md @@ -8,12 +8,14 @@ Every request is validated against one or more auth modes before your handler ru > **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 | +| Mode | Credential required | Typical use case | +| --------------- | -------------------------------------------------- | -------------------------------------- | +| `'user'` | Valid JWT in `Authorization: Bearer ` | Authenticated user endpoints | +| `'publishable'` | Valid `default` publishable key in `apikey` header | Client-facing, key-validated endpoints | +| `'secret'` | Valid `default` secret key in `apikey` header | Server-to-server, internal calls | +| `'none'` | None | Open endpoints, custom auth wrappers | + +> **Bare `'publishable'` / `'secret'` match only the `default` key** in `SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS`. Use `secret:` for a specific key or `secret:*` to accept any key in the set — see [Named key syntax](#named-key-syntax). > **Supabase Edge Functions:** By default, the platform requires a valid JWT on every request same as `'user'`. > If your function uses `'publishable'`, `'secret'` or `'none'`, disable the platform-level JWT check in `supabase/config.toml`: @@ -79,7 +81,7 @@ The caller must send: apikey: sb_publishable_abc123... ``` -By default, `publishable` 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 or `publishable:*` to accept any key (see below). ## Secret mode @@ -103,6 +105,8 @@ The caller must send: apikey: sb_secret_xyz789... ``` +By default, `secret` mode validates against the `"default"` key in `SUPABASE_SECRET_KEYS`. Use named key syntax to target a specific key or `secret:*` to accept any key (see below). + ## None mode No credentials required. Every request is accepted. diff --git a/docs/core-primitives.md b/docs/core-primitives.md index 5a280cd..7f897c8 100644 --- a/docs/core-primitives.md +++ b/docs/core-primitives.md @@ -151,6 +151,8 @@ The client is configured with: - The user's JWT as the `Authorization: Bearer` header (if token is provided) - Server-safe auth settings: `persistSession: false`, `autoRefreshToken: false`, `detectSessionInUrl: false` +**Which key is used.** With `auth.keyName` set, that named key from `SUPABASE_PUBLISHABLE_KEYS` is used (and it throws if the key doesn't exist). With `keyName` omitted, the `default` key is used, falling back to the first key in the set when no `default` exists. Note this differs from the `'publishable'` auth mode, which matches the `default` key only and never falls back. + This function throws `EnvError` if `SUPABASE_URL` or the required publishable key is missing. Wrap in try/catch when using directly. ## createAdminClient @@ -170,6 +172,8 @@ const supabaseAdminInternal = createAdminClient({ }) ``` +**Which key is used.** With `auth.keyName` set, that named key from `SUPABASE_SECRET_KEYS` is used (and it throws if the key doesn't exist). With `keyName` omitted, the `default` key is used, falling back to the first key in the set when no `default` exists. Note this differs from the `'secret'` auth mode, which matches the `default` key only and never falls back. + Same server-safe settings as `createContextClient`. Throws `EnvError` if the secret key is missing. ## Full example: custom multi-route handler diff --git a/docs/security.md b/docs/security.md index bd39e41..da7110a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,29 +21,34 @@ 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 | -| `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 | +| 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` | `default` publishable key (timing-safe) | A known client app | Row-Level Security | Full access | +| `secret` | `default` secret key (timing-safe) | A trusted server/service | Full access | Full access | +| `none` | Nothing — all requests are accepted | Unknown | Row-Level Security | Full access | + +For `publishable` and `secret`, the bare mode matches only the `default` key; use `publishable:` / `secret:` for a specific key or `publishable:*` / `secret:*` to accept any key in the set. 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. -- **`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. +- **`publishable` and `secret` modes** compare the `apikey` header against known keys. The comparison is timing-safe. Bare `auth: 'publishable'` / `auth: 'secret'` match only the `default` key; use named keys (`auth: 'secret:automations'`) to accept only that specific key — this follows the principle of least privilege — or the wildcard (`auth: 'secret:*'`) to accept any key in the set. - **`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 -Instead of accepting any valid API key, you can restrict an endpoint to a specific named key: +Bare `auth: 'secret'` matches only the `default` key. You can restrict an endpoint to a specific named key, or accept any key in the set with the wildcard: ```ts -// Accepts any secret key +// Matches only the "default" secret key withSupabase({ auth: 'secret' }, handler) // Only accepts the "automations" secret key withSupabase({ auth: 'secret:automations' }, handler) + +// Accepts any secret key in the set +withSupabase({ auth: 'secret:*' }, 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. diff --git a/skills/supabase-server/SKILL.md b/skills/supabase-server/SKILL.md index eb5c2b8..e2a020d 100644 --- a/skills/supabase-server/SKILL.md +++ b/skills/supabase-server/SKILL.md @@ -192,7 +192,7 @@ await fetch('https://.supabase.co/functions/v1/my-function', { }) ``` -Use `auth: 'secret'` to accept any secret key, or `auth: 'secret:name'` to require a specific named key. +Bare `auth: 'secret'` matches only the `default` key. Use `auth: 'secret:name'` to require a specific named key, or `auth: 'secret:*'` to accept any secret key in the set. ## When to use `auth: 'none'` diff --git a/src/core/create-admin-client.ts b/src/core/create-admin-client.ts index afe2cf3..6f4ab6b 100644 --- a/src/core/create-admin-client.ts +++ b/src/core/create-admin-client.ts @@ -14,14 +14,29 @@ import { resolveEnv } from './resolve-env.js' * Uses a secret key for authentication, giving full access to all data. * Stateless — one client per request. * + * ## Which key is used + * + * With `auth.keyName` set, that named key from `SUPABASE_SECRET_KEYS` is used — + * and it throws if the key doesn't exist. With `keyName` omitted, the `default` + * key is used, falling back to the first key in the set when no `default` exists. + * + * Note this differs from the `"secret"` auth mode, which matches the `default` + * key only and never falls back — see {@link index.AuthModeWithKey}. + * * @throws {@link index.EnvError} If `SUPABASE_URL` is missing or the specified secret key is not found. * * @example Basic usage * ```ts + * // Uses the `default` secret key (or the first key if no `default` exists) * const supabaseAdmin = createAdminClient() * const { data } = await supabaseAdmin.from('audit_log').insert({ action: 'user_login' }) * ``` * + * @example Specific named key + * ```ts + * const supabaseAdmin = createAdminClient({ auth: { keyName: 'internal' } }) + * ``` + * * @category Primitives */ export function createAdminClient( diff --git a/src/core/create-context-client.ts b/src/core/create-context-client.ts index b43589d..81c8a65 100644 --- a/src/core/create-context-client.ts +++ b/src/core/create-context-client.ts @@ -14,6 +14,16 @@ import { resolveEnv } from './resolve-env.js' * Configured with a publishable key and (optionally) the caller's JWT, * so Row-Level Security policies apply. Stateless — one client per request. * + * ## Which key is used + * + * With `auth.keyName` set, that named key from `SUPABASE_PUBLISHABLE_KEYS` is + * used — and it throws if the key doesn't exist. With `keyName` omitted, the + * `default` key is used, falling back to the first key in the set when no + * `default` exists. + * + * Note this differs from the `"publishable"` auth mode, which matches the + * `default` key only and never falls back — see {@link index.AuthModeWithKey}. + * * @throws {@link index.EnvError} If `SUPABASE_URL` is missing or the specified publishable key is not found. * * @example With verified auth diff --git a/src/index.ts b/src/index.ts index 939a554..a358c1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,10 +22,13 @@ * | Mode | Credential | Use case | * |------|-----------|----------| * | `"user"` | Valid JWT | Authenticated user endpoints | - * | `"publishable"` | Publishable key | Client-facing, key-validated endpoints | - * | `"secret"` | Secret key | Server-to-server, internal calls | + * | `"publishable"` | `default` publishable key | Client-facing, key-validated endpoints | + * | `"secret"` | `default` secret key | Server-to-server, internal calls | * | `"none"` | None | Open endpoints | * + * Bare `"publishable"` / `"secret"` match only the `default` key; use + * `"secret:"` for a specific key or `"secret:*"` to accept any key. + * * Array syntax tries modes in order — first match wins: * ```ts * withSupabase({ auth: ['user', 'secret'] }, handler) diff --git a/src/types.ts b/src/types.ts index 9d33bbb..780a6a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,10 +11,14 @@ export type { JSONWebKeySet } * Authentication mode that determines what credentials a request must provide. * * - `"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). + * - `"publishable"` — Requires a valid publishable key in the `apikey` header. Matches only the `default` key. + * - `"secret"` — Requires a valid secret key in the `apikey` header (timing-safe comparison). Matches only the `default` key. * - `"user"` — Requires a valid JWT in the `Authorization: Bearer ` header. * + * Bare `"publishable"` / `"secret"` resolve the `default` key from + * `SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS`. To target another key or + * accept any key, see {@link AuthModeWithKey}. + * * @example Single mode * ```ts * // Single mode @@ -42,7 +46,8 @@ export type Allow = AuthMode * * 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 `"publishable:*"` or `"secret:*"` to accept any key in the set. + * Use `"publishable:*"` or `"secret:*"` to accept any key in the set. The bare + * form without a colon (`"publishable"` / `"secret"`) matches only the `default` key. * * @example Named key * ```ts