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 @@ -167,7 +167,7 @@ await fetch(refreshEndpoint, {
| `"secret"` | Valid secret key | Server-to-server, internal calls |
| `"always"` | None | Open endpoints, wrappers that handle their own auth |

Array syntax (`allow: ["user", "secret"]`) accepts multiple auth methods — first match wins.
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).

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

Expand Down
22 changes: 11 additions & 11 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,17 +291,17 @@ class AuthError extends Error {

## Error Code Constants

| Constant | Value | Class | Meaning |
| ----------------------------------- | ----------------------------------- | ----------- | --------------------------------- |
| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error |
| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set |
| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found |
| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key |
| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found |
| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key |
| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error |
| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | No credential matched |
| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth |
| Constant | Value | Class | Meaning |
| ----------------------------------- | ----------------------------------- | ----------- | ------------------------------------------------- |
| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error |
| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set |
| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found |
| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key |
| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found |
| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key |
| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error |
| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | No credential matched, or JWT failed verification |
| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth |

---

Expand Down
4 changes: 3 additions & 1 deletion docs/auth-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ 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.

## Named key syntax

When your project has multiple API keys (e.g., separate keys for web, mobile, and internal services), use the colon syntax to validate against a specific named key.
Expand Down Expand Up @@ -198,6 +200,6 @@ withSupabase({ allow: ['user', 'public:web'] }, async (_req, ctx) => {

1. `extractCredentials(request)` reads `Authorization: Bearer <token>` 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`
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`.
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
10 changes: 5 additions & 5 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ Thrown when a required environment variable is missing or malformed. Always `sta

Thrown when authentication or authorization fails. Status is `401` for invalid credentials, `500` for server-side auth failures.

| Code | Status | Meaning |
| ------------------------------ | ------ | ------------------------------------------- |
| `INVALID_CREDENTIALS` | 401 | No credential matched any allowed auth mode |
| `CREATE_SUPABASE_CLIENT_ERROR` | 500 | Auth succeeded but client creation failed |
| `AUTH_ERROR` | 401 | Generic authentication error |
| Code | Status | Meaning |
| ------------------------------ | ------ | ----------------------------------------------------------------------------------------- |
| `INVALID_CREDENTIALS` | 401 | No credential matched any allowed auth mode, or a JWT was present but failed verification |
| `CREATE_SUPABASE_CLIENT_ERROR` | 500 | Auth succeeded but client creation failed |
| `AUTH_ERROR` | 401 | Generic authentication error |

## How errors surface in each layer

Expand Down
2 changes: 2 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ JWT verification in `user` mode works as follows:

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.

## CORS handling

`withSupabase` handles CORS automatically:
Expand Down
3 changes: 3 additions & 0 deletions skills/supabase-server/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Server-side utilities for Supabase. Handles auth, client creation, and context i

- 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.
- Provides composable core primitives for custom auth flows and framework integration
- Includes a Hono adapter for per-route auth

Expand Down Expand Up @@ -199,6 +200,8 @@ Use `allow: 'secret'` to accept any secret key, or `allow: 'secret:name'` to req

**Never use `allow: 'always'` 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.

## Edge Function recipes

### Function-to-function calls
Expand Down
102 changes: 102 additions & 0 deletions src/core/verify-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,4 +416,106 @@ describe('verifyCredentials', () => {
expect(result.data!.authType).toBe('always')
})
})

describe('invalid credential rejection (no silent fallthrough)', () => {
let jwks: JsonWebKeySet

beforeAll(async () => {
const { publicKey } = await generateKeyPair('RS256')
const publicJwk = await exportJWK(publicKey)
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
jwks = { keys: [publicJwk] }
})

it('rejects invalid JWT instead of falling through to always mode', async () => {
const creds: Credentials = { token: 'garbage.jwt.token', apikey: null }
const result = await verifyCredentials(creds, {
allow: ['user', 'always'],
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 () => {
const { privateKey, publicKey } = await generateKeyPair('RS256')
const publicJwk = await exportJWK(publicKey)
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
const expiredJwks = { keys: [publicJwk] }

const expiredToken = await new SignJWT({ sub: 'user-123' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt(Math.floor(Date.now() / 1000) - 7200)
.setExpirationTime(Math.floor(Date.now() / 1000) - 3600)
.sign(privateKey)

const creds: Credentials = { token: expiredToken, apikey: null }
const result = await verifyCredentials(creds, {
allow: ['user', 'always'],
env: makeEnv({ jwks: expiredJwks }),
})
expect(result.error).not.toBeNull()
expect(result.error!.code).toBe(InvalidCredentialsError)
})

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'],
env: makeEnv({ jwks }),
})
expect(result.error).toBeNull()
expect(result.data!.authType).toBe('always')
})

it('rejects invalid JWT even when public mode follows', async () => {
const creds: Credentials = {
token: 'garbage.jwt.token',
apikey: 'sb_publishable_xyz',
}
const result = await verifyCredentials(creds, {
allow: ['user', 'public'],
env: makeEnv({ jwks }),
})
expect(result.error).not.toBeNull()
expect(result.error!.code).toBe(InvalidCredentialsError)
})

it('rejects invalid JWT instead of falling through to secret mode', async () => {
const creds: Credentials = {
token: 'garbage.jwt.token',
apikey: 'sb_secret_xyz',
}
const result = await verifyCredentials(creds, {
allow: ['user', 'secret'],
env: makeEnv({ jwks }),
})
expect(result.error).not.toBeNull()
expect(result.error!.code).toBe(InvalidCredentialsError)
})

it('rejects JWT with missing sub claim', async () => {
const { privateKey, publicKey } = await generateKeyPair('RS256')
const publicJwk = await exportJWK(publicKey)
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
const noSubJwks = { keys: [publicJwk] }

const noSubToken = await new SignJWT({ role: 'authenticated' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(privateKey)

const creds: Credentials = { token: noSubToken, apikey: null }
const result = await verifyCredentials(creds, {
allow: 'user',
env: makeEnv({ jwks: noSubJwks }),
})
expect(result.error).not.toBeNull()
expect(result.error!.code).toBe(InvalidCredentialsError)
})
})
})
25 changes: 19 additions & 6 deletions src/core/verify-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,23 @@ function claimsToUserClaims(claims: JWTClaims): UserClaims {
}
}

const INVALID = Symbol('invalid')

/**
* Attempts to authenticate credentials against a single auth mode.
* Returns the {@link AuthResult} on success, or `null` if the mode doesn't match.
*
* Returns:
* - `AuthResult` on success.
* - `null` if the mode doesn't apply (no relevant credential present — safe to try the next mode).
* - `INVALID` if a credential was present but failed verification (must reject immediately).
*
* @internal
*/
async function tryMode(
mode: AllowWithKey,
credentials: Credentials,
env: SupabaseEnv,
): Promise<AuthResult | null> {
): Promise<AuthResult | typeof INVALID | null> {
const { base, keyName } = parseAllowMode(mode)

switch (base) {
Expand Down Expand Up @@ -166,7 +173,7 @@ async function tryMode(
const jwkSet = createLocalJWKSet(env.jwks)
const { payload } = await jwtVerify(credentials.token, jwkSet)
if (typeof payload.sub !== 'string') {
return null
return INVALID
}
const claims = payload as unknown as JWTClaims
return {
Expand All @@ -177,7 +184,7 @@ async function tryMode(
keyName: null,
}
} catch {
return null
return INVALID
}
}

Expand All @@ -189,8 +196,11 @@ async function tryMode(
/**
* Verifies pre-extracted credentials against one or more allowed auth modes.
*
* Tries each mode in order — first match wins. Use {@link verifyAuth} to extract
* and verify in a single call.
* Tries each mode in order — first match wins. A mode is only tried when its
* credential is present; a JWT that is present but fails verification
* short-circuits the chain with `InvalidCredentialsError` instead of falling
* through to the next mode. Use {@link verifyAuth} to extract and verify in a
* single call.
*
* @param credentials - The credentials to verify (from {@link extractCredentials}).
* @param options - Allowed auth modes and optional env overrides.
Expand Down Expand Up @@ -225,6 +235,9 @@ export async function verifyCredentials(

for (const mode of modes) {
const result = await tryMode(mode, credentials, env)
if (result === INVALID) {
return { data: null, error: Errors[InvalidCredentialsError]() }
}
if (result) {
return { data: result, error: null }
}
Expand Down
7 changes: 6 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import type {
* // Single mode
* withSupabase({ allow: 'user' }, handler)
*
* // Multiple modes — the first match wins
* // 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)
* ```
*/
Expand Down Expand Up @@ -213,6 +216,8 @@ export interface UserClaims {
export interface WithSupabaseConfig {
/**
* Auth mode(s) to accept. Modes are tried in order — the first match wins.
* A mode falls through only when its credential is absent; a present-but-invalid
* JWT short-circuits the chain with `InvalidCredentialsError`.
*
* @defaultValue `"user"`
*/
Expand Down