Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ await fetch(refreshEndpoint, {
| `"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).
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). Because `"none"` matches every request, it is only accepted on its own or as the last entry of a list (`auth: ["user", "none"]` — optional user).

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

Expand Down
29 changes: 28 additions & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,33 @@ type AuthModeWithKey = AuthMode | `publishable:${string}` | `secret:${string}`

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.

### CredentialedAuthMode

```ts
type CredentialedAuthMode = Exclude<AuthModeWithKey, 'none'>
```

Every `AuthModeWithKey` except `'none'`, keyed forms included.

### AuthConfig

```ts
type AuthConfig =
| 'none'
| CredentialedAuthMode
| [CredentialedAuthMode, ...CredentialedAuthMode[]]
| [CredentialedAuthMode, ...CredentialedAuthMode[], 'none']
```

The accepted shape of the `auth` option. `'none'` matches every request, so the type allows it on its own or as the last entry of a list, and nowhere else — `['none']` says nothing that a bare `'none'` doesn't, and a mode placed after `'none'` can never be reached. A single mode needs no wrapping array: `'user'` and `['user']` are the same configuration.

```ts
withSupabase({ auth: 'user' }, handler) // one mode
withSupabase({ auth: ['secret', 'user'] }, handler) // first match wins
withSupabase({ auth: ['user', 'none'] }, handler) // optional user
withSupabase({ auth: 'none' }, handler) // no credentials required
```

### 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.
Expand All @@ -480,7 +507,7 @@ interface SupabaseContext<Database = unknown> {

```ts
interface WithSupabaseConfig {
auth?: AuthModeWithKey | AuthModeWithKey[] // default: 'user'
auth?: AuthConfig // default: 'user'
/** @deprecated use `auth` instead — will be removed in a future major release */
allow?: AuthModeWithKey | AuthModeWithKey[]
env?: Partial<SupabaseEnv>
Expand Down
11 changes: 10 additions & 1 deletion docs/auth-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ Use `none` for health checks, public APIs, or when you handle auth yourself insi

## Array syntax (multiple modes)

Accept multiple auth methods. Modes are tried in order — the first match wins.
Accept multiple auth methods. Modes are tried in order — the first match wins. The array is for lists only: a single mode is written unwrapped (`auth: 'user'`), though `auth: ['user']` means the same thing.

```ts
import { withSupabase } from '@supabase/server'
Expand All @@ -155,6 +155,15 @@ 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.

**Where `'none'` can go.** `'none'` matches every request, so it only earns its place as the **last** entry of a list — `auth: ['user', 'none']` is "a user if one is signed in, anonymous otherwise." Anything after it is unreachable, and a list of just `['none']` says nothing that a bare `auth: 'none'` doesn't. Both are type errors:

```ts
withSupabase({ auth: ['user', 'none'] }, handler) // ✅ optional user
withSupabase({ auth: 'none' }, handler) // ✅ open endpoint
withSupabase({ auth: ['none'] }, handler) // ❌ use the bare 'none'
withSupabase({ auth: ['none', 'user'] }, handler) // ❌ 'user' is unreachable
```

**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
Expand Down
2 changes: 2 additions & 0 deletions skills/supabase-server/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ Bare `auth: 'secret'` matches only the `default` key. Use `auth: 'secret:name'`

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

**`'none'` goes last, or alone.** It matches every request, so the type accepts it only as the final entry of a list (`['user', 'none']`) or on its own (`'none'`). `['none']` and `['none', 'user']` are type errors — write the bare `'none'` for the first, and put `'none'` last for the second.

## Edge Function recipes

### Function-to-function calls
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,12 @@ export type {
export type {
Allow,
AllowWithKey,
AuthConfig,
AuthMode,
AuthModeWithKey,
AuthResult,
ClientAuth,
CredentialedAuthMode,
CreateAdminClientOptions,
CreateContextClientOptions,
Credentials,
Expand Down
68 changes: 68 additions & 0 deletions src/types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expectTypeOf, it } from 'vitest'

import type { AuthConfig, WithSupabaseConfig } from './types.js'

/**
* `AuthConfig` is enforced by `tsc`, not by vitest: the `@ts-expect-error`
* lines below fail `pnpm typecheck` if the type ever starts accepting an
* ordering it shouldn't, and fail it just as loudly if it starts rejecting
* one it should accept (an unused `@ts-expect-error` is itself an error).
*/
describe('AuthConfig', () => {
it('accepts a single mode unwrapped, keyed or not', () => {
expectTypeOf<'user'>().toExtend<AuthConfig>()
expectTypeOf<'secret'>().toExtend<AuthConfig>()
expectTypeOf<'publishable:mobile'>().toExtend<AuthConfig>()
expectTypeOf<'secret:*'>().toExtend<AuthConfig>()
})

it('accepts the wrapped form of a single mode too', () => {
const bare: AuthConfig = 'user'
const wrapped: AuthConfig = ['user']
void bare
void wrapped
})

it('accepts an ordered list of credentialed modes', () => {
const ordered: AuthConfig = ['secret', 'user']
const keyed: AuthConfig = ['user', 'publishable:web_app', 'secret:*']
void ordered
void keyed
})

it('accepts bare "none", and "none" as the last entry of a list', () => {
const bare: AuthConfig = 'none'
const optionalUser: AuthConfig = ['user', 'none']
const optionalAnything: AuthConfig = ['user', 'secret:*', 'none']
void bare
void optionalUser
void optionalAnything
})

it('rejects "none" as a list of one — a bare "none" says the same thing', () => {
// @ts-expect-error "none" needs a credentialed mode before it
const only: AuthConfig = ['none']
void only
})

it('rejects modes placed after "none", which can never be reached', () => {
// @ts-expect-error "none" matches every request, so nothing follows it
const unreachable: AuthConfig = ['none', 'user']
void unreachable
// @ts-expect-error same, with the trailing entry in final position
const sandwiched: AuthConfig = ['user', 'none', 'secret']
void sandwiched
})

it('rejects a repeated "none"', () => {
// @ts-expect-error only the last entry may be "none"
const twice: AuthConfig = ['user', 'none', 'none']
void twice
})

it('is the type of the `auth` option', () => {
expectTypeOf<WithSupabaseConfig['auth']>().toEqualTypeOf<
AuthConfig | undefined
>()
})
})
48 changes: 46 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,45 @@ export type AuthModeWithKey =
*/
export type AllowWithKey = AuthModeWithKey

/**
* An auth mode that requires a credential — every {@link AuthModeWithKey}
* except `"none"`, keyed forms included.
*
* @category Types
*/
export type CredentialedAuthMode = Exclude<AuthModeWithKey, 'none'>

/**
* Accepted shape of the `auth` option: one mode, or an ordered list of modes
* tried left to right.
*
* `"none"` accepts every request, so it only carries meaning as the last
* entry of a list — the modes before it are the ones that can produce an
* identity, and anything after it is unreachable. On its own in a list
* (`["none"]`) it says nothing that a bare `auth: 'none'` doesn't. So the
* type accepts `"none"` alone or in final position behind at least one
* credentialed mode, and nowhere else.
*
* A single mode needs no wrapping array — `"user"` and `["user"]` are the
* same configuration, and the unwrapped form is the one the union names
* first, so it is what shows up in editor completions.
*
* @example Every accepted form
* ```ts
* withSupabase({ auth: 'user' }, handler) // one mode
* withSupabase({ auth: ['secret', 'user'] }, handler) // first match wins
* withSupabase({ auth: ['user', 'none'] }, handler) // optional user
* withSupabase({ auth: 'none' }, handler) // no credentials required
* ```
*
* @category Types
*/
export type AuthConfig =
| 'none'
| CredentialedAuthMode
| [CredentialedAuthMode, ...CredentialedAuthMode[]]
| [CredentialedAuthMode, ...CredentialedAuthMode[], 'none']

/**
* Resolved Supabase environment configuration.
*
Expand Down Expand Up @@ -255,14 +294,19 @@ export interface WithSupabaseConfig {
* A mode falls through only when its credential is absent; a present-but-invalid
* JWT short-circuits the chain with `InvalidCredentialsError`.
*
* `"none"` matches unconditionally, so it belongs last in a list or on its
* own — see {@link AuthConfig}.
*
* @defaultValue `"user"`
*/
auth?: AuthModeWithKey | AuthModeWithKey[]
auth?: AuthConfig

/**
* @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.
* When both `auth` and `allow` are provided, `auth` takes precedence. It keeps
* the older, looser element type so code mid-migration still compiles; `auth`
* is where the {@link AuthConfig} ordering rule is enforced.
*/
allow?: AuthModeWithKey | AuthModeWithKey[]

Expand Down
13 changes: 11 additions & 2 deletions src/with-supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {
} from './core/parts/projections.js'
import { withSupabaseAdminClient } from './middleware/admin-client/index.js'
import { withSupabaseClient } from './middleware/client/index.js'
import type { SupabaseContext, WithSupabaseConfig } from './types.js'
import type {
AuthModeWithKey,
SupabaseContext,
WithSupabaseConfig,
} from './types.js'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyHandler = (req: Request, ctx: any) => Promise<Response>
Expand Down Expand Up @@ -65,7 +69,12 @@ void entryIsSound
/** Whether every configured auth mode requires credentials, so an unauthenticated request never passes the gate. */
function requiresCredentials(config: WithSupabaseConfig): boolean {
const modes = config.auth ?? config.allow ?? 'user'
const list = Array.isArray(modes) ? modes : [modes]
// `auth` keeps `'none'` out of every position but the last; the deprecated
// `allow` still admits it anywhere, so this reads the widened element type
// and scans the whole list.
const list: readonly AuthModeWithKey[] = Array.isArray(modes)
? modes
: [modes]
return !list.includes('none')
}

Expand Down
Loading