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
2 changes: 2 additions & 0 deletions internal/functions/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,14 @@ func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool,
if err != nil {
return err
}
jwks, _ := utils.Config.Auth.ResolveJWKS(ctx)
env = append(env,
fmt.Sprintf("SUPABASE_URL=http://%s:8000", utils.KongAliases[0]),
"SUPABASE_ANON_KEY="+utils.Config.Auth.AnonKey.Value,
"SUPABASE_SERVICE_ROLE_KEY="+utils.Config.Auth.ServiceRoleKey.Value,
"SUPABASE_DB_URL="+dbUrl,
"SUPABASE_INTERNAL_JWT_SECRET="+utils.Config.Auth.JwtSecret.Value,
"SUPABASE_INTERNAL_JWKS="+jwks,
fmt.Sprintf("SUPABASE_INTERNAL_HOST_PORT=%d", utils.Config.Api.Port),
)
if viper.GetBool("DEBUG") {
Expand Down
57 changes: 51 additions & 6 deletions internal/functions/serve/templates/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { STATUS_CODE, STATUS_TEXT } from "https://deno.land/std/http/status.ts";
import * as posix from "https://deno.land/std/path/posix/mod.ts";

import * as jose from "https://deno.land/x/jose@v4.13.1/index.ts";
import * as jose from "jsr:@panva/jose@6";

const SB_SPECIFIC_ERROR_CODE = {
BootError:
Expand Down Expand Up @@ -29,8 +29,9 @@ const SB_SPECIFIC_ERROR_REASON = {
// OS stuff - we don't want to expose these to the functions.
const EXCLUDED_ENVS = ["HOME", "HOSTNAME", "PATH", "PWD"];

const JWT_SECRET = Deno.env.get("SUPABASE_INTERNAL_JWT_SECRET")!;
const HOST_PORT = Deno.env.get("SUPABASE_INTERNAL_HOST_PORT")!;
const JWT_SECRET = Deno.env.get("SUPABASE_INTERNAL_JWT_SECRET")!;
const JWKS_ENDPOINT = new URL('/auth/v1/.well-known/jwks.json', Deno.env.get("SUPABASE_URL")!)
const DEBUG = Deno.env.get("SUPABASE_INTERNAL_DEBUG") === "true";
const FUNCTIONS_CONFIG_STRING = Deno.env.get(
"SUPABASE_INTERNAL_FUNCTIONS_CONFIG",
Expand Down Expand Up @@ -105,18 +106,62 @@ function getAuthToken(req: Request) {
return token;
}

async function verifyJWT(jwt: string): Promise<boolean> {
async function isValidLegacyJWT(jwtSecret: string, jwt: string): Promise<boolean> {
const encoder = new TextEncoder();
const secretKey = encoder.encode(JWT_SECRET);
const secretKey = encoder.encode(jwtSecret);
try {
await jose.jwtVerify(jwt, secretKey);
} catch (e) {
console.error(e);
console.error('Symmetric Legacy JWT verification error', e);
return false;
}
return true;
}

// Lazy-loading JWKs
let jwks = (() => {
try {
// using injected JWKS from cli
return jose.createLocalJWKSet(JSON.parse(Deno.env.get('SUPABASE_INTERNAL_JWKS')));
} catch (error) {
return null
}
})();

async function isValidJWT(jwksUrl: string, jwt: string): Promise<boolean> {
try {
if (!jwks) {
// Loading from remote-url on fly
jwks = jose.createRemoteJWKSet(new URL(jwksUrl));
}
await jose.jwtVerify(jwt, jwks);
Comment on lines +122 to +137
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

wc -l internal/functions/serve/templates/main.ts

Repository: supabase/cli

Length of output: 102


🏁 Script executed:

cat -n internal/functions/serve/templates/main.ts | head -150

Repository: supabase/cli

Length of output: 5779


🌐 Web query:

@panva/jose createLocalJWKSet vs createRemoteJWKSet behavior key rotation

💡 Result:

Key rotation behavior differences

createLocalJWKSet(jwks)

  • Uses only the JWKS object you pass in (“locally stored, or otherwise available”). It will never re-fetch or “learn” rotated keys by itself. If the issuer rotates keys (new kid), you must replace/update the JWKS in your app (e.g., refetch + rebuild your verifier) for verification to start working again. [1]

createRemoteJWKSet(url, options?)

  • Fetches the JWKS from the remote jwks_uri lazily and refreshes when needed:
    • If the incoming token’s header (alg/kid) doesn’t match any cached key, it will fetch the JWKS again (but rate-limited by cooldownDuration to prevent abuse). This is the main mechanism that makes key rotation “just work” in steady state. [2]
    • Independently, it won’t go longer than cacheMaxAge between successful HTTP fetches (default 10 minutes), so it will periodically refresh even without rotation-triggered misses. [3]
  • Recreating the RemoteJWKSet instance on failures is discouraged; it can allow attackers to force constant reloads—keep one instance and let its cooldown/caching behavior do the right thing. [4]

Practical summary for rotation

  • Local JWKS: rotation requires your refresh logic.
  • Remote JWKS: rotation is typically automatic: first request with a new kid triggers a refetch (subject to cooldownDuration), then verification succeeds once the new key appears in JWKS. [2]

Sources:
[1] createLocalJWKSet docs (panva/jose) (github.com)
[2] createRemoteJWKSet docs (panva/jose) (github.com)
[3] RemoteJWKSetOptions (cacheMaxAge, cooldownDuration, defaults) (github.com)
[4] panva guidance on not recreating RemoteJWKSet instance (github.com)

Citations:


🏁 Script executed:

cat -n internal/functions/serve/templates/main.ts | sed -n '131,165p'

Repository: supabase/cli

Length of output: 1384


🏁 Script executed:

# Check if jwks variable is reassigned anywhere else in the file
rg "jwks\s*=" internal/functions/serve/templates/main.ts -n

Repository: supabase/cli

Length of output: 139


Don't freeze asymmetric verification to the startup JWKS snapshot.

If SUPABASE_INTERNAL_JWKS is present, jwks is set to a createLocalJWKSet at startup and never reaches the remote resolver. createLocalJWKSet only resolves against the supplied local keys and does not refresh, while createRemoteJWKSet refetches when encountering an unknown kid (key ID). After a signing-key rotation, valid ES256/RS256 tokens will fail verification until the process restarts. This breaks the stated migration scenario. Either fall back to remote resolution when the local set misses a key, or prefer the remote resolver when available.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/functions/serve/templates/main.ts` around lines 122 - 137, The
current initialization of jwks using createLocalJWKSet when
SUPABASE_INTERNAL_JWKS is present makes isValidJWT always use the static set and
never fall back to remote keys; update isValidJWT (or the jwks initialization)
so that when jwt verification fails due to unknown key ID (kid) it falls back to
a createRemoteJWKSet(new URL(jwksUrl)) resolver and retries verification, or
prefer the remote resolver when SUPABASE_INTERNAL_JWKS exists but may be stale;
specifically modify the logic in isValidJWT to catch key-not-found verification
errors from jose.jwtVerify and then assign jwks = jose.createRemoteJWKSet(new
URL(jwksUrl)) before re-invoking jwtVerify so rotated keys are accepted while
keeping the local set as an initial optimization.

Copy link
Copy Markdown
Member Author

@kallebysantos kallebysantos Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while createRemoteJWKSet refetches when encountering an unknown

Not really true, the previous one also freeze the jwks and was only fetching it at 1º startup.
Also since its local dev, its already expected to users full restart after config.toml changes or similar.

} catch (e) {
console.error('Asymmetric JWT verification error', e);
return false;
}
return true;
}

/**
* Applies hybrid JWT verification, using JWK as primary and Legacy Secret as fallback.
* Use only during 'New JWT Keys' migration period, while `JWT_SECRET` is still available.
*/
export async function verifyHybridJWT(jwtSecret: string, jwksUrl: string, jwt: string): Promise<boolean> {
const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt)

if (jwtAlgorithm === 'HS256') {
console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`)

return await isValidLegacyJWT(jwtSecret, jwt)
}

if (jwtAlgorithm === 'ES256' || jwtAlgorithm === 'RS256') {
return await isValidJWT(jwksUrl, jwt)
}

return false;
}

// Ref: https://docs.deno.com/examples/checking_file_existence/
async function shouldUsePackageJsonDiscovery({ entrypointPath, importMapPath }: FunctionConfig): Promise<boolean> {
if (importMapPath) {
Expand Down Expand Up @@ -159,7 +204,7 @@ Deno.serve({
if (req.method !== "OPTIONS" && functionsConfig[functionName].verifyJWT) {
try {
const token = getAuthToken(req);
const isValidJWT = await verifyJWT(token);
const isValidJWT = await verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token);

if (!isValidJWT) {
return getResponse({ msg: "Invalid JWT" }, STATUS_CODE.Unauthorized);
Expand Down
Loading