diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 2a73af8..79e6425 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -2,25 +2,29 @@ On Supabase Platform and Local Development (CLI), all variables are auto-provisioned — no configuration needed -| Variable | Format | Description | Available in | -| --------------------------- | ---------------------------------- | -------------------------------------------- | --------------------------------- | -| `SUPABASE_URL` | `https://.supabase.co` | Your Supabase project URL | All | -| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_..."}` | Named publishable keys as JSON object | All | -| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_..."}` | Named secret keys as JSON object | All | -| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | Inline JSON Web Key Set for JWT verification | All | -| `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key (fallback) | Self-hosted, if manually exported | -| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key (fallback) | Self-hosted, if manually exported | +| Variable | Format | Description | Available in | +| --------------------------- | ----------------------------------- | -------------------------------------------- | --------------------------------- | +| `SUPABASE_URL` | `https://.supabase.co` | Your Supabase project URL | All | +| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_..."}` | Named publishable keys as JSON object | All | +| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_..."}` | Named secret keys as JSON object | All | +| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | Inline JSON Web Key Set for JWT verification | All | +| `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, if manually exported | +| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key (fallback) | Self-hosted, if manually exported | ## Non-Supabase environments (Node.js, Bun, Cloudflare, self-hosted) Set these based on which auth modes your app uses: -| Variable | Required when | -| -------------------------------------- | ----------------------------------------- | -| `SUPABASE_URL` | Always | -| `SUPABASE_SECRET_KEY` | `auth: 'secret'` or using `supabaseAdmin` | -| `SUPABASE_PUBLISHABLE_KEY` | `auth: 'publishable'` | -| `SUPABASE_JWKS` or `SUPABASE_JWKS_URL` | `auth: 'user'` (JWT verification) | +| Variable | Required when | +| -------------------------------------- | ------------------------------------------ | +| `SUPABASE_URL` | Always | +| `SUPABASE_SECRET_KEY` | `auth: 'secret'` or using `supabaseAdmin` | +| `SUPABASE_PUBLISHABLE_KEY` | `auth: 'publishable'` | +| `SUPABASE_JWKS` or `SUPABASE_JWKS_URL` | `auth: 'user'` (JWT verification) | +| `SUPABASE_JWT_AUDIENCE` | Optional - restricts accepted JWT audience | +| `SUPABASE_JWT_ISSUER` | Optional - restricts accepted JWT issuer | ### Minimal `.env` example @@ -193,6 +197,8 @@ interface SupabaseEnv { secretKeys: Record // `URL` when SUPABASE_JWKS is a remote endpoint, `JsonWebKeySet` for inline keys jwks: JsonWebKeySet | URL | null + audience?: string + issuer?: string } ``` diff --git a/docs/security.md b/docs/security.md index bd39e41..34a7bb0 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.jwtClaims` +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.jwtClaims` 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. `auth: ['user', 'publishable']`), 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 `'none'`) 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.test.ts b/src/core/resolve-env.test.ts index 3a4afc0..1791bba 100644 --- a/src/core/resolve-env.test.ts +++ b/src/core/resolve-env.test.ts @@ -22,6 +22,18 @@ describe('resolveEnv', () => { expect(result.data!.url).toBe('https://test.supabase.co') }) + it('reads optional JWT audience and issuer from process.env', () => { + vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co') + vi.stubEnv('SUPABASE_JWT_AUDIENCE', 'https://test.supabase.co') + vi.stubEnv('SUPABASE_JWT_ISSUER', 'https://test.supabase.co/auth/v1') + + const result = resolveEnv() + + expect(result.error).toBeNull() + expect(result.data!.audience).toBe('https://test.supabase.co') + expect(result.data!.issuer).toBe('https://test.supabase.co/auth/v1') + }) + it('parses JSON publishable keys', () => { vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co') vi.stubEnv( diff --git a/src/core/resolve-env.ts b/src/core/resolve-env.ts index 28af954..4939df9 100644 --- a/src/core/resolve-env.ts +++ b/src/core/resolve-env.ts @@ -177,6 +177,8 @@ export function resolveEnv( overrides?.secretKeys ?? resolveKeys('SUPABASE_SECRET_KEY', 'SUPABASE_SECRET_KEYS'), jwks: overrides?.jwks ?? resolveJwks(), + 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.test.ts b/src/core/verify-credentials.test.ts index ef0bffb..2ac9fc3 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -277,11 +277,13 @@ describe('verifyCredentials', () => { describe('user mode', () => { let jwks: JsonWebKeySet + let privateKey: CryptoKey let validToken: string beforeAll(async () => { - const { privateKey, publicKey } = await generateKeyPair('RS256') - const publicJwk = await exportJWK(publicKey) + const keyPair = await generateKeyPair('RS256') + privateKey = keyPair.privateKey + const publicJwk = await exportJWK(keyPair.publicKey) publicJwk.alg = 'RS256' publicJwk.use = 'sig' jwks = { keys: [publicJwk] } @@ -312,6 +314,69 @@ describe('verifyCredentials', () => { expect(result.data!.token).toBe(validToken) }) + it('succeeds when JWT audience and issuer match configured values', async () => { + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256' }) + .setAudience('https://test.supabase.co') + .setIssuer('https://test.supabase.co/auth/v1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const result = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + env: makeEnv({ + jwks, + audience: 'https://test.supabase.co', + issuer: 'https://test.supabase.co/auth/v1', + }), + }, + ) + + expect(result.error).toBeNull() + expect(result.data!.jwtClaims!.aud).toBe('https://test.supabase.co') + expect(result.data!.jwtClaims!.iss).toBe( + 'https://test.supabase.co/auth/v1', + ) + }) + + it.each([ + [ + 'audience', + 'https://wrong.supabase.co', + 'https://test.supabase.co/auth/v1', + ], + [ + 'issuer', + 'https://test.supabase.co', + 'https://wrong.supabase.co/auth/v1', + ], + ])( + 'fails when configured JWT %s does not match', + async (_label, audience, issuer) => { + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256' }) + .setAudience('https://test.supabase.co') + .setIssuer('https://test.supabase.co/auth/v1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const result = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + env: makeEnv({ jwks, audience, issuer }), + }, + ) + + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }, + ) + it('fails with invalid JWT', async () => { const creds: Credentials = { token: 'invalid.jwt.token', apikey: null } const result = await verifyCredentials(creds, { diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index 5c2d1e6..7dd2e2f 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -3,6 +3,7 @@ import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey, + type JWTVerifyOptions, } from 'jose' import { AuthError, Errors, InvalidCredentialsError } from '../errors.js' @@ -211,7 +212,10 @@ async function tryMode( if (!env.jwks) return null try { const jwkSet = getJwksResolver(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 438e11e..2d215a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -101,6 +101,18 @@ export interface SupabaseEnv { * `null` rather than falling through to the other variable. */ jwks: JsonWebKeySet | URL | 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 } /**