Skip to content

feat(functions): add hybrid JWT verification to TS edge runtime#5560

Draft
avallete wants to merge 10 commits into
developfrom
claude/edge-runtime-jwt-asymmetric-lj8cqb
Draft

feat(functions): add hybrid JWT verification to TS edge runtime#5560
avallete wants to merge 10 commits into
developfrom
claude/edge-runtime-jwt-asymmetric-lj8cqb

Conversation

@avallete

@avallete avallete commented Jun 12, 2026

Copy link
Copy Markdown
Member

Port the asymmetric JWT support from the Go CLI (#4721, #4985) to
the TypeScript edge runtime. The runtime now verifies ES256/RS256 tokens
against a JWKS, falling back to the legacy symmetric secret for HS256, and
exposes the JWKS to user workers via SUPABASE_JWKS.

How this differs from the Go implementation

This is not a 1:1 port. The Go CLI verifies tokens with a JWT library,
but the edge runtime's auth gate lives in the main worker script that the CLI
injects as source into the runtime — it can't rely on bundled npm
dependencies. So verification here is implemented by hand directly on
WebCrypto (crypto.subtle), with no jose/JWT library:

  • HS256crypto.subtle.verify with the raw symmetric secret (legacy path).
  • ES256 / RS256crypto.subtle.importKey("jwk", …) + verify, with
    per-algorithm import/verify params (ECDSA/P-256, RSASSA-PKCS1-v1_5).

Tokens are also checked for exp/nbf (with a small clock-skew window) before
the signature, matching the Go/Docker runtime, which reject expired tokens by
default.

JWKS resolution

Key resolution is hybrid and ordered: the JWKS injected by the CLI
(config.jwks) is checked first, then the auth service's
/auth/v1/.well-known/jwks.json endpoint. Candidate keys are filtered by kty
and kid (keys without a kid stay eligible so single-key sets that omit it
still verify). Remote JWKS responses are cached per-URL; failed and empty
responses are not cached.

Note that the CLI-injected config.jwks is currently symmetric-only (an
oct key derived from jwtSecret), so it never matches an ES256/RS256 token
today — that local check is future-proofing for when the local stack grows
asymmetric signing keys, and real asymmetric verification currently resolves
through the well-known endpoint. Sourcing SUPABASE_JWKS from configured
signing keys / third-party providers is tracked as a follow-up (it first
requires wiring asymmetric signing into the local auth service).

SUPABASE_JWKS is also forwarded into the user worker environment so function
code can read the same key set the gate uses.

https://claude.ai/code/session_01Vn3KQfhzbP2X3QNqs36sKR

Port the asymmetric JWT support from the Go CLI (#4721, #4985)
to the TypeScript edge runtime. The runtime now verifies ES256/RS256 tokens
against a JWKS, falling back to the legacy symmetric secret for HS256, and
exposes the JWKS to user workers via SUPABASE_JWKS.

https://claude.ai/code/session_01Vn3KQfhzbP2X3QNqs36sKR
@avallete avallete requested a review from a team as a code owner June 12, 2026 05:35
@avallete avallete requested a review from kallebysantos June 12, 2026 05:36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fc5d8d95bc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/stack/src/services/edge-runtime-main.ts Outdated
Add an e2e case that exercises the real Deno edge runtime: missing
credentials and forged HS256 tokens are rejected, while a validly-signed
HS256 token and the apikey -> minted sb-api-key compatibility path are
accepted.

https://claude.ai/code/session_01Vn3KQfhzbP2X3QNqs36sKR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7fdcbd4ca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/stack/tests/createStack.e2e.test.ts Outdated
@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown

Supabase CLI preview

npx --yes https://pkg.pr.new/supabase@5560

Preview package for commit c134206.

claude added 2 commits June 12, 2026 07:36
- Type the lazy jose import via a string specifier + hand-written interface so
  every workspace that compiles the text-embedded runtime file (including
  apps/cli) type-checks without resolving the Deno-only `jsr:` module.
- Fall back to the remote well-known JWKS when the injected local JWKS has no
  key matching an asymmetric token, instead of failing closed.
- Re-enable JWT verification explicitly in the e2e test; reloadFunctions() with
  no options preserves the original noVerifyJwt config.

https://claude.ai/code/session_01Vn3KQfhzbP2X3QNqs36sKR
The lazy dynamic import used a non-literal specifier, which the edge runtime
cannot statically discover and pre-load, so jose failed to import at runtime
and JWT verification threw (HTTP 500 instead of 401).

Inject a static `import * as jose from "jsr:@panva/jose@6"` when materializing
the runtime script, and consume it as an ambient `jose` global in the source.
This matches the Go template's runtime import while keeping the `jsr:` specifier
out of the bun-typed source entirely (no .d.ts, triple-slash, or knip ignore).

https://claude.ai/code/session_01Vn3KQfhzbP2X3QNqs36sKR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3ab48f16e0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/stack/src/services/edge-runtime-main.ts Outdated
Comment thread packages/stack/src/services/edge-runtime.ts Outdated
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

Replace the jose/jsr dependency with self-contained WebCrypto verification:
HS256 via crypto.subtle HMAC (restoring the proven legacy path) and ES256/RS256
by importing JWK public keys from the local JWKS, falling back to the auth
service's well-known endpoint. The remote JWKS is cached per URL.

This removes the runtime failure where a valid HS256 token was rejected, and
avoids resolving a Deno-only jsr module at edge-runtime startup (so stacks with
no functions, noVerifyJwt, or no network still boot).

https://claude.ai/code/session_01Vn3KQfhzbP2X3QNqs36sKR

@avallete avallete left a comment

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.

Reviewed the hybrid JWT verification port. The verifier architecture is sound: hand-rolling on WebCrypto is the right call for an injected script with no bundled deps, algorithm confusion is handled correctly (alg allow-list, HS256 never touches JWKS keys, asymmetric candidates filtered by kty), and the failed-fetch cache eviction is done properly.

Two things I'd address before merge, detailed inline:

  1. Missing exp/nbf validation — expired tokens with valid signatures are accepted, diverging from the Go/Docker runtime this ports (jose/golang-jwt validate expiry by default).
  2. fetchRemoteJwks can permanently cache an empty key set when the auth service responds non-2xx with a JSON body (no res.ok check).

Also inline: the local-JWKS branch is currently dead code for asymmetric tokens (the injected JWKS is oct-only), the ES256/RS256 paths have no test coverage (suggest unit tests on exported helpers rather than more e2e), and the new e2e test flips noVerifyJwt on the shared stack without restoring it.


Generated by Claude Code

Comment thread packages/stack/src/services/edge-runtime-main.ts Outdated
Comment thread packages/stack/src/services/edge-runtime-main.ts
Comment thread packages/stack/src/services/edge-runtime-main.ts Outdated
Comment thread packages/stack/tests/createStack.e2e.test.ts
Comment thread packages/stack/src/services/edge-runtime-main.ts
- Reject expired (exp) / not-yet-valid (nbf) tokens with clock skew, matching
  the Go/Docker runtime behavior
- Skip caching empty/failed JWKS responses (res.ok check + empty-set eviction)
- Match keyless JWKS entries so single-key sets that omit kid still verify
- Drop per-candidate verification log noise
- Export verifyHybridJwt/verifyWithJwks/areClaimsValid and add unit coverage
  for the ES256/RS256, kid-filtering, remote-fallback, and claim paths
- Restore noVerifyJwt on the shared e2e stack after the hybrid test

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c83bda6b6c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/stack/src/services/edge-runtime-main.ts Outdated
Comment thread packages/stack/src/services/edge-runtime-main.ts Outdated
Comment thread packages/stack/src/services/edge-runtime-main.ts Outdated
Comment thread packages/stack/src/services/edge-runtime-main.ts
- Add a 5-minute TTL to the remote JWKS cache so rotated/revoked signing keys
  stop verifying against a stale cached set without a runtime restart
- Reject non-object JWT payloads (e.g. JSON null) so they fail the auth gate
  with a 401 instead of throwing into a 500
- Reject non-numeric exp/nbf claims rather than silently skipping them
- Cover the TTL refetch, null payload, and malformed-claim cases in unit tests

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 80e5620b8a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/stack/src/services/edge-runtime-main.ts Outdated
A JWT claims set must be a JSON object; an array payload (e.g. []) was
treated as an object and skipped the temporal-claim checks.
Comment thread packages/stack/src/services/edge-runtime-main.ts
…WKS gap

The CLI-injected JWKS is symmetric-only (oct), so it could never verify an
ES256/RS256 token; remove the dead local-JWKS-first branch and verify
asymmetric tokens against the auth service's well-known JWKS only. Keep the oct
JWKS in SUPABASE_JWKS (correct for the default local HS256 issuer, matching the
Go CLI's ResolveJWKS fallback) and add a TODO to also resolve signing_keys_path
/ third_party keys. Rework the asymmetric unit tests to exercise the remote
path.
@avallete avallete enabled auto-merge June 12, 2026 14:46
@avallete avallete requested a review from jgoux June 12, 2026 14:47

@jgoux jgoux left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the solid cleanup through the review rounds. I verified the final head locally:

  • pnpm --filter @supabase/stack test:core
  • pnpm --filter @supabase/stack check:all
  • pnpm --filter @supabase/stack exec bun --bun vitest run --project e2e tests/createStack.e2e.test.ts

I found one remaining correctness gap that I think should be addressed before merging: the PR claims hybrid/asymmetric support for the TS edge runtime, but the TS stack still only resolves an HS256 secret-derived JWKS and does not wire auth.signing_keys_path / auth.third_party into either the auth service or the function SUPABASE_JWKS env. That means the new verifier works for mocked remote JWKS and the legacy HS256 path, but still does not provide the Go-side parity for configured local asymmetric keys/third-party providers.

Two lower-severity follow-ups I noticed:

  • The added e2e is valuable, but it only exercises HS256 plus the apikey compatibility path; ES256/RS256 remain unit-only with mocked fetch. A runtime-level asymmetric smoke test would make this much stronger.
  • The shared e2e stack should restore noVerifyJwt in a finally, so a failed assertion does not leak JWT enforcement into later tests.

Comment thread packages/stack/src/functions.ts
expect(await res.text()).toBe("later");
});

test("enforces hybrid JWT verification on Edge Functions", { timeout: 20_000 }, async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This e2e name says “hybrid JWT verification,” but the scenario only covers missing credentials, forged HS256, valid HS256, and the apikey compatibility path. The ES256/RS256 branch is only covered in unit tests with a mocked JWKS fetch, so we still do not have runtime proof that the actual edge-runtime + gateway + auth JWKS path works. Please add an asymmetric runtime smoke test or rename/scope this e2e to the HS256 legacy path it actually exercises.

writeFunction(projectDir, "secure", "secure");
// The stack was created with noVerifyJwt; re-enable verification explicitly
// (reloadFunctions() with no opts would keep the original config).
await stack.reloadFunctions({ noVerifyJwt: false });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

After this flips the shared stack to noVerifyJwt: false, any assertion failure before the restore at the end leaves JWT verification enabled for the remaining tests in this file. Please wrap the assertions in try/finally and restore noVerifyJwt: true in the finally block so later tests do not inherit hidden state after a failure.

@avallete

Copy link
Copy Markdown
Member Author

After discussion with @kallebysantos I'll turn this back into a draft and let him take over. Implementation of the auth for edge functions might see some changes soon.

@avallete avallete marked this pull request as draft June 18, 2026 11:06
auto-merge was automatically disabled June 18, 2026 11:06

Pull request was converted to draft

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants