Skip to content
Open
3 changes: 3 additions & 0 deletions packages/stack/src/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ResolvedFunctionConfig,
} from "@supabase/config";
import { Effect, FileSystem, Path, Redacted } from "effect";
import { generateJwks } from "./JwtGenerator.ts";
import type { ResolvedStackConfig } from "./StackBuilder.ts";

export interface FunctionsConfig {
Expand All @@ -27,6 +28,7 @@ export interface FunctionsRuntimeConfig {
readonly publishableKey: string;
readonly secretKey: string;
readonly jwtSecret: string;
readonly jwks: string;
readonly env: Readonly<Record<string, string>>;
readonly functions: Readonly<
Record<
Expand Down Expand Up @@ -198,6 +200,7 @@ export const resolveFunctionsRuntimeConfig = Effect.fnUntraced(function* (
publishableKey: stackConfig.publishableKey,
secretKey: stackConfig.secretKey,
jwtSecret: stackConfig.jwtSecret,
jwks: generateJwks(stackConfig.jwtSecret),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Build SUPABASE_JWKS from project auth config

For projects that configure asymmetric auth inputs such as auth.signing_keys_path or auth.third_party (both are loaded by @supabase/config), this writes a JWKS derived only from the legacy jwtSecret. As a result, ES256/RS256 tokens signed by the configured local signing key or third-party provider are absent from SUPABASE_JWKS; the fallback only asks the local GoTrue JWKS endpoint, not the configured provider/file, so Edge Functions reject tokens that the hybrid verifier is meant to support. Resolve the JWKS from the loaded project auth config rather than always calling generateJwks(stackConfig.jwtSecret).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Leaving this one open as a known limitation rather than fixing it in this PR, because the local stack doesn't yet support asymmetric signing locally:

  • The local auth service is configured with the symmetric secret only — StackBuilder passes version, port, siteUrl, jwtExpiry, and externalUrl to the auth service, but not auth.signing_keys_path or auth.third_party. So local GoTrue mints HS256 tokens signed with jwtSecret, and generateJwks(stackConfig.jwtSecret) is the correct local key set for them.
  • Since no asymmetric key is wired into the local issuer, putting signing_keys_path/third_party keys into SUPABASE_JWKS wouldn't match any locally-minted token today.
  • Externally-minted asymmetric tokens are still handled by the remote /auth/v1/.well-known/jwks.json fallback for keys the local auth service is aware of.

Properly sourcing SUPABASE_JWKS from the configured signing keys / third-party providers requires first plumbing those into the local auth service so it actually signs with them — that's a larger, separate change. Happy to file a follow-up issue if you'd like to track it.


Generated by Claude Code

env,
functions: Object.fromEntries(
enabledManifest.map(([slug, config]) => [
Expand Down
4 changes: 4 additions & 0 deletions packages/stack/src/functions.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ describe("stack Functions runtime config", () => {
CONFIG_ONLY: "from-project-env",
SHARED: "from-project-env",
});
// JWKS is injected so the edge runtime can verify asymmetric JWTs.
expect(JSON.parse(config!.jwks)).toEqual({
keys: [expect.objectContaining({ kty: "oct", k: expect.any(String) })],
});
}).pipe(
Effect.provide(BunServices.layer),
Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))),
Expand Down
191 changes: 167 additions & 24 deletions packages/stack/src/services/edge-runtime-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,37 +27,178 @@ function base64UrlToBytes(value: string) {
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
}

function bytesEqual(left: Uint8Array, right: Uint8Array) {
if (left.byteLength !== right.byteLength) return false;
let result = 0;
for (let i = 0; i < left.byteLength; i++) {
result |= left[i]! ^ right[i]!;
}
return result === 0;
interface Jwk {
readonly kty?: string;
readonly kid?: string;
}

async function isValidLocalJwt(secret: string, jwt: string) {
const parts = jwt.split(".");
if (parts.length !== 3) return false;
const [header, payload, signature] = parts;
const decodedHeader = JSON.parse(new TextDecoder().decode(base64UrlToBytes(header!)));
interface Jwks {
readonly keys: ReadonlyArray<Jwk>;
}

// WARN:(kallebysantos) Go version supports Asymmetric JWTs (ES256 | RS256) via SUPABASE_JWKS env
// It must be ported to TS as well
if (decodedHeader.alg !== "HS256") return false;
// Asymmetric algorithms supported during the migration to new JWT keys, mapped
// to the WebCrypto import/verify parameters used to validate their signatures.
const ASYMMETRIC_ALGORITHMS = {
ES256: {
kty: "EC",
importParams: { name: "ECDSA", namedCurve: "P-256" },
verifyParams: { name: "ECDSA", hash: "SHA-256" },
},
RS256: {
kty: "RSA",
importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
verifyParams: { name: "RSASSA-PKCS1-v1_5" },
},
};

type AsymmetricAlgorithm = keyof typeof ASYMMETRIC_ALGORITHMS;

async function verifyLegacyHs256(secret: string, signingInput: string, signature: string) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
["verify"],
);
const signed = await crypto.subtle.sign(
return crypto.subtle.verify(
"HMAC",
key,
new TextEncoder().encode(`${header}.${payload}`),
base64UrlToBytes(signature),
new TextEncoder().encode(signingInput),
);
}

export async function verifyWithJwks(
alg: AsymmetricAlgorithm,
kid: string | undefined,
signingInput: string,
signature: string,
jwks: Jwks,
) {
const spec = ASYMMETRIC_ALGORITHMS[alg];
const data = new TextEncoder().encode(signingInput);
const sig = base64UrlToBytes(signature);
// Match by key type, and by `kid` when both the token and the key carry one.
// Keys without a `kid` stay eligible so single-key sets that omit it (some
// issuers do) still verify — mirroring the more lenient Go-side behavior.
const candidates = jwks.keys.filter(
(key) =>
key.kty === spec.kty && (kid === undefined || key.kid === undefined || key.kid === kid),
);
return bytesEqual(new Uint8Array(signed), base64UrlToBytes(signature!));
for (const jwk of candidates) {
try {
const key = await crypto.subtle.importKey("jwk", jwk, spec.importParams, false, ["verify"]);
if (await crypto.subtle.verify(spec.verifyParams, key, sig, data)) return true;
} catch {
// Not a usable/matching key (bad import or wrong signature) — try the next
// candidate. Logging here fires per key per request, so stay quiet.
}
}
return false;
Comment thread
avallete marked this conversation as resolved.
}

function toJwks(value: unknown): Jwks {
if (value !== null && typeof value === "object" && "keys" in value && Array.isArray(value.keys)) {
return { keys: value.keys };
}
return { keys: [] };
}

// Cache the well-known JWKS per URL so asymmetric verification does not refetch
// `/auth/v1/.well-known/jwks.json` on every request. A short TTL bounds how long
// a rotated or revoked signing key can keep verifying against a stale cached set
// before the runtime picks up the issuer's current keys.
const REMOTE_JWKS_TTL_MS = 5 * 60 * 1000;
const remoteJwksCache = new Map<string, { cachedAt: number; jwks: Promise<Jwks> }>();

function fetchRemoteJwks(jwksUrl: string): Promise<Jwks> {
const cached = remoteJwksCache.get(jwksUrl);
Comment thread
depthfirst-app[bot] marked this conversation as resolved.
if (cached && Date.now() - cached.cachedAt < REMOTE_JWKS_TTL_MS) return cached.jwks;
const cachedAt = Date.now();
const jwks = fetch(jwksUrl)
.then((res) => {
// Without this, a non-2xx response with a JSON body (e.g. a 502/404 from
// the gateway during startup) would parse into an empty key set and be
// cached, rejecting every asymmetric token until the entry expires.
if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
return res.json();
})
.then(toJwks)
.then((parsed) => {
// Don't cache an empty key set: the auth service may not have published
// its keys yet, so allow a refetch on the next request instead.
if (parsed.keys.length === 0) remoteJwksCache.delete(jwksUrl);
return parsed;
});
jwks.catch(() => remoteJwksCache.delete(jwksUrl));
remoteJwksCache.set(jwksUrl, { cachedAt, jwks });
return jwks;
}

// Reject expired (`exp`) or not-yet-valid (`nbf`) tokens regardless of
// signature, matching the Go/Docker runtime (jose / golang-jwt validate these
// by default). A small clock-skew window avoids spurious failures locally.
export function areClaimsValid(payload: string): boolean {
let claims: unknown;
try {
claims = JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload)));
} catch {
return false;
}
// A valid JWT payload must be a JSON object; reject anything else (e.g. `null`,
// a bare string, or an array) rather than dereferencing it and throwing past
// the gate or treating a non-claims-set as valid.
if (typeof claims !== "object" || claims === null || Array.isArray(claims)) return false;
const { exp, nbf } = claims as { exp?: unknown; nbf?: unknown };
const now = Math.floor(Date.now() / 1000);
const skew = 60;
// exp/nbf must be NumericDate (a number) per the JWT spec — reject malformed
// non-numeric values instead of silently skipping the check.
if (exp !== undefined && (typeof exp !== "number" || exp + skew < now)) return false;
if (nbf !== undefined && (typeof nbf !== "number" || nbf - skew > now)) return false;
return true;
}
Comment thread
avallete marked this conversation as resolved.

// Hybrid JWT verification: asymmetric (ES256 | RS256) tokens are verified
// against the JWKS, with the legacy symmetric secret (HS256) as a fallback.
// Mirrors the Go CLI runtime (supabase/cli#4721, #4985) during the migration
// to the new asymmetric JWT keys.
export async function verifyHybridJwt(config: any, jwt: string) {
const parts = jwt.split(".");
if (parts.length !== 3) return false;
const [header, payload, signature] = parts;
const signingInput = `${header}.${payload}`;

let decodedHeader: { alg?: string; kid?: string };
try {
decodedHeader = JSON.parse(new TextDecoder().decode(base64UrlToBytes(header!)));
} catch (error) {
console.error("Failed to decode JWT header", error);
return false;
}

if (!areClaimsValid(payload!)) return false;

try {
if (decodedHeader.alg === "HS256") {
return await verifyLegacyHs256(config.jwtSecret, signingInput, signature!);
}
if (decodedHeader.alg === "ES256" || decodedHeader.alg === "RS256") {
const { alg, kid } = decodedHeader;
// The local auth service signs HS256 with the symmetric secret and does not
// publish asymmetric signing keys, so ES256/RS256 tokens are verified against
// its well-known JWKS (covering keys minted elsewhere). The CLI does not yet
// resolve `auth.signing_keys_path` / `auth.third_party` locally — see the
// SUPABASE_JWKS TODO in serveFunction.
const jwksUrl = new URL("/auth/v1/.well-known/jwks.json", config.supabaseUrl).href;
const remoteJwks = await fetchRemoteJwks(jwksUrl);
return await verifyWithJwks(alg, kid, signingInput, signature!, remoteJwks);
}
} catch (error) {
console.error("JWT verification failed", error);
}
return false;
}

async function verifyRequest(req: Request, config: any, functionConfig: any) {
Expand All @@ -79,11 +220,7 @@ async function verifyRequest(req: Request, config: any, functionConfig: any) {
return Response.json({ msg: "Auth header is not 'Bearer {token}'" }, { status: 401 });
}

try {
if (await isValidLocalJwt(config.jwtSecret, token)) return null;
} catch (error) {
console.error("JWT verification failed", error);
}
if (await verifyHybridJwt(config, token)) return null;
return Response.json({ msg: "Invalid JWT" }, { status: 401 });
}

Expand All @@ -108,6 +245,12 @@ async function serveFunction(req: Request, config: any, functionName: string, fu
SUPABASE_DB_URL: config.dbUrl,
SUPABASE_PUBLISHABLE_KEYS: JSON.stringify({ default: config.publishableKey }),
SUPABASE_SECRET_KEYS: JSON.stringify({ default: config.secretKey }),
// The oct(jwtSecret) JWKS the Server SDK uses to verify user JWTs — correct
// for the default local HS256 issuer.
// TODO: also resolve from auth.signing_keys_path / auth.third_party (like the
// Go CLI's Auth.ResolveJWKS) so SDK `auth: "user"` works for projects
// configured with asymmetric signing keys.
SUPABASE_JWKS: config.jwks,
Comment thread
avallete marked this conversation as resolved.
});

try {
Expand Down
Loading
Loading