From 31fbfbd6d06b3b70ad72f113cbffa2e42646088e Mon Sep 17 00:00:00 2001 From: Alan Zabihi Date: Fri, 24 Apr 2026 11:51:28 +0200 Subject: [PATCH] fix: validate JWT audience and issuer claims jwtVerify was called without audience or issuer options, so any JWT signed by a key in the JWKS was accepted regardless of who issued it or who it was intended for. In setups where multiple services share signing keys, a token from one service would pass verification on another. Add optional SUPABASE_JWT_AUDIENCE and SUPABASE_JWT_ISSUER env vars. When set, they are forwarded to jose's jwtVerify options. Existing behavior is unchanged when they are not set. --- docs/environment-variables.md | 6 ++++++ docs/security.md | 8 ++++++-- src/core/resolve-env.ts | 2 ++ src/core/verify-credentials.ts | 7 +++++-- src/types.ts | 12 ++++++++++++ 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 64af836..ef8ff35 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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://.supabase.co` | Expected JWT `aud` claim (optional) | All | +| `SUPABASE_JWT_ISSUER` | `https://.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 | @@ -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 @@ -177,6 +181,8 @@ interface SupabaseEnv { publishableKeys: Record secretKeys: Record jwks: JsonWebKeySet | null + audience?: string + issuer?: string } ``` diff --git a/docs/security.md b/docs/security.md index 6d5a02b..5875775 100644 --- a/docs/security.md +++ b/docs/security.md @@ -55,11 +55,15 @@ JWT verification in `user` mode works as follows: 1. The `Authorization: Bearer ` 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 diff --git a/src/core/resolve-env.ts b/src/core/resolve-env.ts index a27ae4f..efec91f 100644 --- a/src/core/resolve-env.ts +++ b/src/core/resolve-env.ts @@ -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 } diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index c82ff14..52f1b1a 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -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 { @@ -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 } diff --git a/src/types.ts b/src/types.ts index 7393d9e..8561c77 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 } /**