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
34 changes: 20 additions & 14 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<ref>.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://<ref>.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://<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, 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

Expand Down Expand Up @@ -193,6 +197,8 @@ interface SupabaseEnv {
secretKeys: Record<string, string>
// `URL` when SUPABASE_JWKS is a remote endpoint, `JsonWebKeySet` for inline keys
jwks: JsonWebKeySet | URL | 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.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
Expand Down
12 changes: 12 additions & 0 deletions src/core/resolve-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
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 @@ -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 }
Expand Down
69 changes: 67 additions & 2 deletions src/core/verify-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] }
Expand Down Expand Up @@ -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, {
Expand Down
6 changes: 5 additions & 1 deletion src/core/verify-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createRemoteJWKSet,
jwtVerify,
type JWTVerifyGetKey,
type JWTVerifyOptions,
} from 'jose'

import { AuthError, Errors, InvalidCredentialsError } from '../errors.js'
Expand Down Expand Up @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down