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
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,19 +400,20 @@ export default {

Automatically available in Supabase Edge Functions:

| Variable | Format | Description |
| --------------------------- | ------------------------------------------------------------- | ------------------------------------- |
| `SUPABASE_URL` | `https://<ref>.supabase.co` | Your project URL |
| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_...","web":"sb_publishable_..."}` | Publishable API keys (named) |
| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_...","web":"sb_secret_..."}` | Secret API keys (named) |
| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | JSON Web Key Set for JWT verification |
| Variable | Format | Description |
| --------------------------- | ------------------------------------------------------------- | -------------------------------------------- |
| `SUPABASE_URL` | `https://<ref>.supabase.co` | Your project URL |
| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_...","web":"sb_publishable_..."}` | Publishable API keys (named) |
| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_...","web":"sb_secret_..."}` | Secret API keys (named) |
| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | Inline JSON Web Key Set for JWT verification |

Also supported (for local dev, self-hosted, or other runtimes):

| Variable | Format | Description |
| -------------------------- | -------------------- | ---------------------- |
| `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key |
| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key |
| Variable | Format | Description |
| -------------------------- | -------------------- | --------------------------------------------------------- |
| `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key |
| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key |
| `SUPABASE_JWKS_URL` | `https://...` | Remote JWKS endpoint (used when `SUPABASE_JWKS` is unset) |

When both singular and plural forms are set, plural takes priority.

Expand Down
56 changes: 36 additions & 20 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,25 @@

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 `[...]` | 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_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` | `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) |

### Minimal `.env` example

Expand Down Expand Up @@ -75,19 +75,34 @@ The singular form is a convenience for the common case where you only have one k

When both singular and plural forms are set, the plural form takes priority.

## JWKS format
## JWKS source

`SUPABASE_JWKS` accepts two formats:
JWT verification (`auth: 'user'`) needs a JWKS. There are two ways to provide one:

```
# Standard JWKS format
# Inline JSON — standard JWKS format
SUPABASE_JWKS={"keys":[{"kty":"RSA","n":"...","e":"AQAB"}]}

# Bare array (convenience)
# Inline JSON — bare array (convenience, wrapped as { keys: [...] })
SUPABASE_JWKS=[{"kty":"RSA","n":"...","e":"AQAB"}]

# Remote JWKS endpoint — keys are fetched on demand and cached in memory.
# HTTPS is required for any non-loopback host; plain http:// is rejected
# (a MITM on the JWKS fetch could swap in an attacker-controlled key and
# forge JWTs that verify). http:// is allowed for loopback hosts only —
# `localhost`, `127.0.0.0/8`, `::1` — to support the local Supabase CLI.
SUPABASE_JWKS_URL=https://<ref>.supabase.co/auth/v1/.well-known/jwks.json

# Local development against `supabase start`:
SUPABASE_JWKS_URL=http://localhost:54321/auth/v1/.well-known/jwks.json
```

When `SUPABASE_JWKS` is not set, JWT verification (`auth: 'user'`) is unavailable.
### Resolution order

1. `SUPABASE_JWKS` — when set, treated as authoritative inline JSON.
2. `SUPABASE_JWKS_URL` — only checked when `SUPABASE_JWKS` is unset or empty.
Must be `https://`, except loopback hosts may use `http://`.
3. Otherwise — `null`. JWT verification (`auth: 'user'`) is unavailable.

## Runtime-specific behavior

Expand Down Expand Up @@ -176,7 +191,8 @@ interface SupabaseEnv {
url: string
publishableKeys: Record<string, string>
secretKeys: Record<string, string>
jwks: JsonWebKeySet | null
// `URL` when SUPABASE_JWKS is a remote endpoint, `JsonWebKeySet` for inline keys
jwks: JsonWebKeySet | URL | null
Comment thread
kallebysantos marked this conversation as resolved.
}
```

Expand Down
113 changes: 110 additions & 3 deletions src/core/resolve-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'

import { resolveEnv } from './resolve-env.js'
import { MissingSupabaseURLError } from '../errors.js'
import type { JsonWebKeySet } from '../types.js'

describe('resolveEnv', () => {
afterEach(() => {
Expand Down Expand Up @@ -67,6 +68,111 @@ describe('resolveEnv', () => {
expect(result.data!.jwks).toBeNull()
})

it('parses SUPABASE_JWKS_URL into a URL object', () => {
vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co')
vi.stubEnv(
'SUPABASE_JWKS_URL',
'https://test.supabase.co/auth/v1/.well-known/jwks.json',
)
const result = resolveEnv()
expect(result.data!.jwks).toBeInstanceOf(URL)
expect((result.data!.jwks as URL).href).toBe(
'https://test.supabase.co/auth/v1/.well-known/jwks.json',
)
})

it.each([
['plain hostname', 'http://example.com/jwks.json'],
['public IP', 'http://1.2.3.4/jwks.json'],
['private IP (non-loopback)', 'http://10.0.0.1/jwks.json'],
['localhost prefix attack', 'http://localhost.evil.com/jwks.json'],
])(
'rejects http SUPABASE_JWKS_URL on non-loopback host (%s)',
(_label, value) => {
vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co')
vi.stubEnv('SUPABASE_JWKS_URL', value)
const result = resolveEnv()
expect(result.data!.jwks).toBeNull()
},
)

it.each([
['localhost', 'http://localhost:54321/auth/v1/.well-known/jwks.json'],
['127.0.0.1', 'http://127.0.0.1:54321/auth/v1/jwks'],
['127.x range', 'http://127.0.0.5/jwks.json'],
['::1', 'http://[::1]:54321/jwks.json'],
['*.localhost subdomain', 'http://api.localhost/jwks.json'],
])(
'allows http SUPABASE_JWKS_URL for loopback host (%s)',
(_label, value) => {
vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co')
vi.stubEnv('SUPABASE_JWKS_URL', value)
const result = resolveEnv()
expect(result.data!.jwks).toBeInstanceOf(URL)
},
)

it.each([
['unclosed IPv6 bracket', 'https://[invalid'],
['scheme only, no host', 'https://'],
])('returns null for malformed SUPABASE_JWKS_URL (%s)', (_label, value) => {
vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co')
vi.stubEnv('SUPABASE_JWKS_URL', value)
const result = resolveEnv()
expect(result.data!.jwks).toBeNull()
})

it('trims whitespace around SUPABASE_JWKS_URL values', () => {
vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co')
vi.stubEnv('SUPABASE_JWKS_URL', ' https://example.com/jwks.json\n')
const result = resolveEnv()
expect(result.data!.jwks).toBeInstanceOf(URL)
expect((result.data!.jwks as URL).href).toBe(
'https://example.com/jwks.json',
)
})

it('rejects a URL value placed in SUPABASE_JWKS (mixed-type protection)', () => {
vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co')
vi.stubEnv('SUPABASE_JWKS', 'https://example.com/jwks.json')
const result = resolveEnv()
expect(result.data!.jwks).toBeNull()
})

it('SUPABASE_JWKS wins over SUPABASE_JWKS_URL when both are set', () => {
vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co')
const inline = { keys: [{ kty: 'RSA', n: 'inline', e: 'AQAB' }] }
vi.stubEnv('SUPABASE_JWKS', JSON.stringify(inline))
vi.stubEnv('SUPABASE_JWKS_URL', 'https://example.com/jwks.json')
const result = resolveEnv()
expect(result.data!.jwks).toEqual(inline)
})

it('does not fall through to SUPABASE_JWKS_URL when SUPABASE_JWKS is malformed', () => {
vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co')
vi.stubEnv('SUPABASE_JWKS', 'not-json')
vi.stubEnv('SUPABASE_JWKS_URL', 'https://example.com/jwks.json')
const result = resolveEnv()
expect(result.data!.jwks).toBeNull()
})

it('falls through to SUPABASE_JWKS_URL when SUPABASE_JWKS is unset or empty', () => {
vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co')
vi.stubEnv('SUPABASE_JWKS', '')
vi.stubEnv('SUPABASE_JWKS_URL', 'https://example.com/jwks.json')
const result = resolveEnv()
expect(result.data!.jwks).toBeInstanceOf(URL)
})

it('passes URL overrides through unchanged', () => {
const url = new URL('https://example.com/jwks.json')
const result = resolveEnv({
url: 'https://test.supabase.co',
jwks: url,
})
expect(result.data!.jwks).toBe(url)
})

it.each([
['a primitive', '1'],
['an empty object', '{}'],
Expand Down Expand Up @@ -138,11 +244,12 @@ describe('resolveEnv', () => {
default: 'sb_secret_fake_default_key_val',
internal: 'sb_secret_fake_internal_key',
})
expect(result.data!.jwks!.keys).toHaveLength(2)
expect((result.data!.jwks!.keys[0] as Record<string, unknown>).kid).toBe(
const jwks = result.data!.jwks as JsonWebKeySet
expect(jwks.keys).toHaveLength(2)
expect((jwks.keys[0] as Record<string, unknown>).kid).toBe(
'cb770052-bdd3-4f5e-8d6f-8836046b7c93',
)
expect((result.data!.jwks!.keys[1] as Record<string, unknown>).kid).toBe(
expect((jwks.keys[1] as Record<string, unknown>).kid).toBe(
'9a9933f7-e18f-4d6f-a791-9a992845a27b',
)
})
Expand Down
74 changes: 67 additions & 7 deletions src/core/resolve-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,16 @@ function resolveKeys(
}

/**
* Parses a JWKS JSON string into a {@link JsonWebKeySet}.
* Accepts both `{ keys: [...] }` and bare `[...]` array formats.
* Returns `null` if the input is missing or malformed.
* Parses an inline JWKS JSON string. Accepts `{ keys: [...] }` or a bare
* array `[...]` (wrapped as `{ keys: [...] }`). Returns `null` for missing
* or malformed input.
*
* @internal
*/
function parseJwks(raw: string | undefined): JsonWebKeySet | null {
if (!raw) return null
try {
const parsed = JSON.parse(raw)
// Support both { keys: [...] } and bare array [...] formats
if (Array.isArray(parsed)) return { keys: parsed }
if (parsed?.keys && Array.isArray(parsed.keys))
return parsed as JsonWebKeySet
Expand All @@ -77,12 +77,72 @@ function parseJwks(raw: string | undefined): JsonWebKeySet | null {
}
}

/**
* Returns true if the hostname is a loopback address — `localhost`,
* `*.localhost`, `127.0.0.0/8`, or `::1`. Browsers treat these as secure
* contexts because traffic never leaves the machine.
*
* @internal
*/
function isLoopbackHost(hostname: string): boolean {
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true
// URL.hostname keeps the brackets for IPv6 literals (e.g. "[::1]").
if (hostname === '[::1]') return true
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) return true
return false
}

/**
* Parses a JWKS endpoint URL. `https://` is always accepted. Plain `http://`
* is accepted only for loopback hosts (`localhost`, `127.0.0.0/8`, `::1`) so
* the Supabase CLI flow works against `http://localhost:54321`. For any
* other host, http is rejected: a MITM on the JWKS fetch could swap in an
* attacker-controlled key and forge JWTs that pass verification. Returns
* `null` for missing or malformed input.
*
* @internal
*/
function parseJwksUrl(raw: string | undefined): URL | null {
if (!raw) return null
const trimmed = raw.trim()
try {
const url = new URL(trimmed)
if (url.protocol === 'https:') return url
if (url.protocol === 'http:' && isLoopbackHost(url.hostname)) return url
return null
} catch {
return null
}
}

/**
* Resolves the JWKS source from `SUPABASE_JWKS` (inline JSON) or
* `SUPABASE_JWKS_URL` (https endpoint). `SUPABASE_JWKS` wins when set;
* `SUPABASE_JWKS_URL` is only consulted if `SUPABASE_JWKS` is absent. Each
* variable is treated as authoritative — if set but malformed, the result is
* `null` and the other variable is *not* consulted as a fallback.
*
* @internal
*/
function resolveJwks(): JsonWebKeySet | URL | null {
const rawJwks = getEnvVar('SUPABASE_JWKS')
if (rawJwks && rawJwks.trim()) {
return parseJwks(rawJwks)
}
const rawJwksUrl = getEnvVar('SUPABASE_JWKS_URL')
if (rawJwksUrl && rawJwksUrl.trim()) {
return parseJwksUrl(rawJwksUrl)
}
return null
}

/**
* Resolves Supabase environment configuration from runtime environment variables.
*
* Reads `SUPABASE_URL`, keys (`SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS`),
* and `SUPABASE_JWKS`. Works across Deno, Node.js, and Bun. For Cloudflare Workers,
* use `overrides` or enable node-compat.
* and the JWKS source (`SUPABASE_JWKS` for inline keys, or `SUPABASE_JWKS_URL`
* for a remote endpoint). Works across Deno, Node.js, and Bun. For Cloudflare
* Workers, use `overrides` or enable node-compat.
*
* @param overrides - Partial values that take precedence over env vars.
* @returns `{ data: SupabaseEnv, error: null }` on success, `{ data: null, error: EnvError }` on failure.
Expand Down Expand Up @@ -116,7 +176,7 @@ export function resolveEnv(
secretKeys:
overrides?.secretKeys ??
resolveKeys('SUPABASE_SECRET_KEY', 'SUPABASE_SECRET_KEYS'),
jwks: overrides?.jwks ?? parseJwks(getEnvVar('SUPABASE_JWKS')),
jwks: overrides?.jwks ?? resolveJwks(),
}

return { data, error: null }
Expand Down
Loading
Loading