Skip to content
Open
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
6 changes: 6 additions & 0 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ On Supabase Platform and Local Development (CLI), all variables are auto-provisi
| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_..."}` | Named publishable keys as JSON object | Platform, Local Development (CLI) |
| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_..."}` | Named secret keys as JSON object | Platform, Local Development (CLI) |
| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | JSON Web Key Set for JWT verification | Platform, Local Development (CLI) |
| `SUPABASE_JWT_AUDIENCE` | `https://<ref>.supabase.co` | Expected JWT `aud` claim (optional) | All |
| `SUPABASE_JWT_ISSUER` | `https://<ref>.supabase.co/auth/v1`| Expected JWT `iss` claim (optional) | All |
| `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key (fallback) | Self-hosted |
| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key (fallback) | Self-hosted |

Expand All @@ -21,6 +23,8 @@ Set these based on which auth modes your app uses:
| `SUPABASE_SECRET_KEY` | `allow: 'secret'` or using `supabaseAdmin` |
| `SUPABASE_PUBLISHABLE_KEY` | `allow: 'public'` |
| `SUPABASE_JWKS` | `allow: 'user'` (JWT verification) |
| `SUPABASE_JWT_AUDIENCE` | Optional — restricts accepted JWT audience |
| `SUPABASE_JWT_ISSUER` | Optional — restricts accepted JWT issuer |

### Minimal `.env` example

Expand Down Expand Up @@ -177,6 +181,8 @@ interface SupabaseEnv {
publishableKeys: Record<string, string>
secretKeys: Record<string, string>
jwks: JsonWebKeySet | null
audience?: string
issuer?: string
}
```

Expand Down
8 changes: 6 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,15 @@ JWT verification in `user` mode works as follows:
1. The `Authorization: Bearer <token>` header is extracted from the request
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`
4. If `SUPABASE_JWT_AUDIENCE` is set, the token's `aud` claim must match
5. If `SUPABASE_JWT_ISSUER` is set, the token's `iss` claim must match
6. The token must contain a `sub` (subject) claim to be considered valid
7. On success, the decoded claims are available as `ctx.userClaims` and `ctx.claims`

If JWKS is not configured (`SUPABASE_JWKS` is missing or malformed), `user` mode is unavailable and will always reject requests.

**Audience and issuer validation.** In setups where multiple services share the same signing keys, a JWT minted by one service could be accepted by another. Setting `SUPABASE_JWT_AUDIENCE` and `SUPABASE_JWT_ISSUER` prevents this by rejecting tokens that weren't issued for your specific service. Both are optional for backward compatibility but recommended in multi-service deployments.

**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
Expand Down
2 changes: 2 additions & 0 deletions src/core/resolve-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ export function resolveEnv(
overrides?.secretKeys ??
resolveKeys('SUPABASE_SECRET_KEY', 'SUPABASE_SECRET_KEYS'),
jwks: overrides?.jwks ?? parseJwks(getEnvVar('SUPABASE_JWKS')),
audience: overrides?.audience ?? getEnvVar('SUPABASE_JWT_AUDIENCE'),
issuer: overrides?.issuer ?? getEnvVar('SUPABASE_JWT_ISSUER'),
}

return { data, error: null }
Expand Down
7 changes: 5 additions & 2 deletions src/core/verify-credentials.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createLocalJWKSet, jwtVerify } from 'jose'
import { createLocalJWKSet, jwtVerify, type JWTVerifyOptions } from 'jose'

import { AuthError, Errors, InvalidCredentialsError } from '../errors.js'
import type {
Expand Down Expand Up @@ -171,7 +171,10 @@ async function tryMode(
if (!env.jwks) return null
try {
const jwkSet = createLocalJWKSet(env.jwks)
const { payload } = await jwtVerify(credentials.token, jwkSet)
const options: JWTVerifyOptions = {}
if (env.audience) options.audience = env.audience
if (env.issuer) options.issuer = env.issuer
const { payload } = await jwtVerify(credentials.token, jwkSet, options)
if (typeof payload.sub !== 'string') {
return INVALID
}
Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ export interface SupabaseEnv {
* `null` when no JWKS is configured (JWT verification will be unavailable).
*/
jwks: JsonWebKeySet | null

/**
* Expected JWT audience (`aud` claim). When set, tokens with a different
* audience are rejected. Sourced from `SUPABASE_JWT_AUDIENCE`.
*/
audience?: string

/**
* Expected JWT issuer (`iss` claim). When set, tokens from a different
* issuer are rejected. Sourced from `SUPABASE_JWT_ISSUER`.
*/
issuer?: string
}

/**
Expand Down