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
8 changes: 6 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,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.jwtClaims`
4. If `audience` is configured, the token's `aud` claim must match
5. If `issuer` is configured, 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. Passing `audience` and `issuer` options (e.g. `withSupabase({ auth: 'user', issuer: fromSupabaseUrl(SUPABASE_URL) })` or `withRequiredClaims({ issuer: fromSupabaseUrl(SUPABASE_URL) })`) 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
Expand Down
2 changes: 2 additions & 0 deletions src/core/parts/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const withAuthGate: Middleware<
const { data, error } = await verifyAuth(req, {
auth: config.auth,
allow: config.allow,
audience: config.audience,
issuer: config.issuer,
env: config.env,
})
if (error) return errorResponse(error, { errors: config.errors })
Expand Down
4 changes: 4 additions & 0 deletions src/core/verify-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export interface VerifyAuthOptions {
* provided, `auth` wins.
*/
allow?: AuthModeWithKey | AuthModeWithKey[]
/** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */
audience?: string | string[]
/** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */
issuer?: string | string[]

/** Optional environment overrides (passed through to {@link resolveEnv}). */
env?: Partial<SupabaseEnv>
Expand Down
139 changes: 134 additions & 5 deletions src/core/verify-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,17 +289,18 @@ describe('verifyCredentials', () => {
describe('user mode', () => {
let jwks: JSONWebKeySet
let validTokens: string[]
let privateKey: CryptoKey
let jwtSecret: CryptoKey | Uint8Array<ArrayBufferLike>

beforeAll(async () => {
// Asymmetric JWK
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'
publicJwk.kid = 'asymmetric-key-id'

// Symmetric Shared Secret JWK
const jwtSecret = await generateSecret('HS256', {
jwtSecret = await generateSecret('HS256', {
extractable: true,
})
const symmetricJwk = await exportJWK(jwtSecret)
Expand Down Expand Up @@ -347,6 +348,134 @@ describe('verifyCredentials', () => {
}
})

it('succeeds when JWT audience and issuer match configured values', async () => {
const token = await new SignJWT({ sub: 'user-123' })
.setProtectedHeader({ alg: 'RS256', kid: 'asymmetric-key-id' })
.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',
audience: 'https://test.supabase.co',
issuer: 'https://test.supabase.co/auth/v1',
env: makeEnv({ jwks }),
},
)

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', kid: 'asymmetric-key-id' })
.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',
audience,
issuer,
env: makeEnv({ jwks }),
},
)

expect(result.error).not.toBeNull()
expect(result.error!.code).toBe(InvalidJwtError)
},
)

it.each([
['audience', ''],
['issuer', ''],
['audience', ['']],
['issuer', ['']],
])('fails when JWT %s option is empty', async (field, value) => {
const token = await new SignJWT({ sub: 'user-123' })
.setProtectedHeader({ alg: 'RS256', kid: 'asymmetric-key-id' })
.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',
[field]: value,
env: makeEnv({ jwks }),
},
)

expect(result.error).not.toBeNull()
expect(result.error!.code).toBe(InvalidJwtError)
expect(result.error!.message).toContain(
`the configured "${field}" option is empty`,
)
})

it('supports audience and issuer validation with symmetric HS256 keys', async () => {
const token = await new SignJWT({ sub: 'user-123' })
.setProtectedHeader({
alg: 'HS256',
kid: 'symmetric-shared-secret-key-id',
})
.setAudience('https://test.supabase.co')
.setIssuer('https://test.supabase.co/auth/v1')
.setIssuedAt()
.setExpirationTime('1h')
.sign(jwtSecret)

const matchResult = await verifyCredentials(
{ token, apikey: null },
{
auth: 'user',
audience: 'https://test.supabase.co',
issuer: 'https://test.supabase.co/auth/v1',
env: makeEnv({ jwks }),
},
)
expect(matchResult.error).toBeNull()

const mismatchResult = await verifyCredentials(
{ token, apikey: null },
{
auth: 'user',
audience: 'https://other.supabase.co',
env: makeEnv({ jwks }),
},
)
expect(mismatchResult.error).not.toBeNull()
expect(mismatchResult.error!.code).toBe(InvalidJwtError)
})

it('fails with invalid JWT', async () => {
const creds: Credentials = { token: 'invalid.jwt.token', apikey: null }
const result = await verifyCredentials(creds, {
Expand Down
12 changes: 10 additions & 2 deletions src/core/verify-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export interface VerifyCredentialsOptions {
* both are provided, `auth` wins.
*/
allow?: AuthModeWithKey | AuthModeWithKey[]
/** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */
audience?: string | string[]
/** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */
issuer?: string | string[]

/** Optional environment overrides (passed through to {@link resolveEnv}). */
env?: Partial<SupabaseEnv>
Expand Down Expand Up @@ -183,6 +187,7 @@ async function tryMode(
mode: AuthModeWithKey,
credentials: Credentials,
env: SupabaseEnv,
options?: VerifyCredentialsOptions,
): Promise<ModeOutcome> {
const { base, keyName } = parseAuthMode(mode)

Expand Down Expand Up @@ -265,7 +270,10 @@ async function tryMode(
return { kind: 'skip', skip: { reason: 'jwks-not-configured' } }
}

const verified = await verifyUserJwt(credentials.token, env.jwks)
const verified = await verifyUserJwt(credentials.token, env.jwks, {
audience: options?.audience,
issuer: options?.issuer,
})
if (!verified.ok) {
const { failure } = verified
return {
Expand Down Expand Up @@ -507,7 +515,7 @@ export async function verifyCredentials(

const skips: ModeSkip[] = []
for (const mode of modes) {
const outcome = await tryMode(mode, credentials, env)
const outcome = await tryMode(mode, credentials, env, options)
if (outcome.kind === 'match') {
return { data: outcome.auth, error: null }
}
Expand Down
48 changes: 46 additions & 2 deletions src/core/verify-user-jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
jwtVerify,
type JWTPayload,
type JWTVerifyGetKey,
type JWTVerifyOptions,
} from 'jose'

import type { JWTClaims, UserClaims } from '../types.js'
Expand Down Expand Up @@ -183,6 +184,12 @@ const MalformedTokenHint =
'The Authorization header must carry a compact JWS — three base64url segments separated by dots. ' +
'Check the token was not truncated, URL-encoded, or wrapped in quotes.'

/** @internal */
export interface VerifyUserJwtOptions {
audience?: string | string[]
issuer?: string | string[]
}

/**
* Verifies a user JWT against the project JWKS — the single verification core
* shared by `verifyCredentials`'s `user` mode and the `withClaims` /
Expand All @@ -205,6 +212,7 @@ const MalformedTokenHint =
export async function verifyUserJwt(
token: string,
jwks: JSONWebKeySet | URL,
options?: VerifyUserJwtOptions,
): Promise<VerifyUserJwtResult> {
let alg: string | undefined
let kid: string | undefined
Expand Down Expand Up @@ -244,10 +252,46 @@ export async function verifyUserJwt(
}
}

if (
options?.audience === '' ||
(options?.audience as unknown) === null ||
(Array.isArray(options?.audience) && options.audience.some((a) => !a))
) {
return {
ok: false,
failure: {
kind: 'token',
reason: 'the configured "audience" option is empty',
hint: 'Pass a non-empty string or array, or omit the option to skip audience validation.',
jwt,
},
}
}
if (
options?.issuer === '' ||
(options?.issuer as unknown) === null ||
(Array.isArray(options?.issuer) && options.issuer.some((i) => !i))
) {
return {
ok: false,
failure: {
kind: 'token',
reason: 'the configured "issuer" option is empty',
hint: 'Pass a non-empty string or array, or omit the option to skip issuer validation.',
jwt,
},
}
}
Comment thread
mandarini marked this conversation as resolved.

try {
const jwkResolver = getJwksResolver(jwks)
let payload: JWTPayload | null = null

const verifyOptions: JWTVerifyOptions = {
Comment thread
mandarini marked this conversation as resolved.
audience: options?.audience,
issuer: options?.issuer,
}

// Symmetric algorithm requires importing the shared secret
if (alg === 'HS256') {
// A remote resolver fetches only from inside `jwtVerify`; its `jwks()`
Expand Down Expand Up @@ -277,10 +321,10 @@ export async function verifyUserJwt(
}
const sharedSecret = await importJWK(jwk, 'HS256')

const verify = await jwtVerify(token, sharedSecret)
const verify = await jwtVerify(token, sharedSecret, verifyOptions)
payload = verify.payload
} else {
const verify = await jwtVerify(token, jwkResolver)
const verify = await jwtVerify(token, jwkResolver, verifyOptions)
payload = verify.payload
}

Expand Down
2 changes: 2 additions & 0 deletions src/create-supabase-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export async function createSupabaseContext<Database = UntypedDatabase>(
const { data: auth, error } = await verifyAuth(request, {
auth: options?.auth,
allow: options?.allow,
audience: options?.audience,
issuer: options?.issuer,
env: options?.env,
})
if (error) {
Expand Down
49 changes: 49 additions & 0 deletions src/middleware/claims/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,53 @@ describe('withClaims', () => {
message: expect.stringMatching(/^\[@supabase\/server\]/),
})
})

it('respects audience and issuer options in withClaims', async () => {
const { privateKey, publicKey } = await generateKeyPair('RS256')
const publicJwk = await exportJWK(publicKey)
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
publicJwk.kid = 'claims-aud-test'
const testJwks = { keys: [publicJwk] }

const validToken = await new SignJWT({
sub: 'user-123',
role: 'authenticated',
})
.setProtectedHeader({ alg: 'RS256', kid: 'claims-aud-test' })
.setAudience('expected-audience')
.setIssuer('expected-issuer')
.setIssuedAt()
.setExpirationTime('1h')
.sign(privateKey)

const matchHandler = withClaims(
{
jwks: testJwks,
audience: 'expected-audience',
issuer: 'expected-issuer',
},
async (_req, ctx) => Response.json({ claims: ctx.jwtClaims }),
)
const matchRes = await matchHandler(requestWithToken(validToken))
expect(matchRes.status).toBe(200)

const mismatchHandler = withClaims(
{ jwks: testJwks, audience: 'wrong-audience' },
async (_req, ctx) => Response.json({ claims: ctx.jwtClaims }),
)
const mismatchRes = await mismatchHandler(requestWithToken(validToken))
expect(mismatchRes.status).toBe(401)

const emptyAudHandler = withClaims(
{ jwks: testJwks, audience: '' },
async (_req, ctx) => Response.json({ claims: ctx.jwtClaims }),
)
const emptyAudRes = await emptyAudHandler(requestWithToken(validToken))
expect(emptyAudRes.status).toBe(401)
const emptyAudBody = await emptyAudRes.json()
expect(emptyAudBody.message).toContain(
'the configured "audience" option is empty',
)
})
})
Loading
Loading