Skip to content

feat: add hybrid jwt verification#4721

Merged
avallete merged 7 commits intosupabase:developfrom
kallebysantos:fix-update-functions-verify-jwt
Mar 10, 2026
Merged

feat: add hybrid jwt verification#4721
avallete merged 7 commits intosupabase:developfrom
kallebysantos:fix-update-functions-verify-jwt

Conversation

@kallebysantos
Copy link
Copy Markdown
Member

@kallebysantos kallebysantos commented Jan 14, 2026

What kind of change does this PR introduce?

Bug fix, feature

What is the current behavior?

Since API keys did change, users can't call functions without manually disabling verify_jwt.

What is the new behavior?

JWKs are now default exposed #4688, so It allows to verify both new asymmetric tokens as well legacy ones.

This way it applies a temporary fix while migrating API keys.

@kallebysantos kallebysantos requested a review from a team as a code owner January 14, 2026 11:38
@coveralls
Copy link
Copy Markdown

coveralls commented Jan 14, 2026

Pull Request Test Coverage Report for Build 22905960749

Details

  • 2 of 2 (100.0%) changed or added relevant lines in 1 file are covered.
  • 5 unchanged lines in 1 file lost coverage.
  • Overall coverage decreased (-0.02%) to 61.713%

Files with Coverage Reduction New Missed Lines %
internal/utils/git.go 5 57.14%
Totals Coverage Status
Change from base Build 22905835723: -0.02%
Covered Lines: 7719
Relevant Lines: 12508

💛 - Coveralls

@joelpramos
Copy link
Copy Markdown

joelpramos commented Feb 15, 2026

@kallebysantos Deno.env.get doesn't seem to have access to "SUPABASE_INTERNAL_*" for me. Is there any property that needs enablement?

const JWT_SECRET = Deno.env.get('SUPABASE_INTERNAL_JWT_SECRET')!;
const HOST_PORT = Deno.env.get('SUPABASE_INTERNAL_HOST_PORT')!;
const DEBUG = Deno.env.get('SUPABASE_INTERNAL_DEBUG') === 'true';
const FUNCTIONS_CONFIG_STRING = Deno.env.get(
  'SUPABASE_INTERNAL_FUNCTIONS_CONFIG'
)!;

async function verifyLegacyJWT(
  jwt: string
): Promise<JWTVerifyResult<JWTPayload> | undefined> {
  // const JWT_SECRET = Deno.env.get('SUPABASE_INTERNAL_JWT_SECRET')!;
  logger.debug('JWT secret: ' + JWT_SECRET);
  logger.debug('Host port: ' + HOST_PORT);
  logger.debug('Debug: ' + DEBUG);
  logger.debug('Functions config string: ' + FUNCTIONS_CONFIG_STRING);
  const encoder = new TextEncoder();
  const secretKey = encoder.encode(JWT_SECRET);
  try {
    return await jwtVerify(jwt, secretKey);
  } catch (e) {
    logger.error('Symmetric Legacy JWT verification error', e);
    return undefined;
  }
}
image

@kallebysantos
Copy link
Copy Markdown
Member Author

Hi @joelpramos 💚
Could you give me more context about your version? Are you calling it inside an edge function or just trying my fork?

@joelpramos
Copy link
Copy Markdown

hi @kallebysantos:

  • Supabase version 2.75.0
  • Local (self hosted)
  • Just trying to replicate your middleware change, not using your fork
  • Getting the var from within a Supabase edge function (my middleware, trying to replicate what you have here as I have this problem locally)
  • I can see the vars injected in the container itself and confirmed by opening the shell and printing it

@kallebysantos
Copy link
Copy Markdown
Member Author

kallebysantos commented Feb 16, 2026

Hey @joelpramos 💚
The SUPABASE_INTERNAL_* are only available inside CLI context as internal envs for the docker local-dev stack — So it only works in this fork / supabase start command (only for internal main.ts scope)

Since you're using self-hosted, the main/index.ts template already implements the legacy verification and the env is named as JWT_SECRET.

I'm not really sure, but new JWK are not available for self-hosting yet, so you "can't" replicate this hybrid replication — At least without extra manual steps.

cc @aantti can give you more details about Self-Host situation of new Asymmetric Keys.

@aantti
Copy link
Copy Markdown

aantti commented Feb 16, 2026

can give you more details about Self-Host situation of new Asymmetric Keys

It's WIP :) Hoping to add it soon.

@joelpramos
Copy link
Copy Markdown

@aantti @kallebysantos at least locally the new solution works totally fine for users but not for service_user (e.g., functions triggered from Cron). I am also having some issues in the deployed instance with functions triggered from cron jobs but haven't triaged yet the root cause and missing some logs.

fwiw other than the fact the variable wasn't available, this hybrid solution you posted @kallebysantos works for me i.e. seems like the service_role from the cron user is using the old auth method. Is that something on both your radars?

@kallebysantos
Copy link
Copy Markdown
Member Author

Hey @joelpramos

service_role...is using the old auth method

Yes, ANON_KEY and SERVICE_ROLE uses old auth (symmetric JWT) which can be verified from *JWT_SECRET

...fine for users but not for the service_role...

I don't think so, user's tokens is now being issued as new JWT (Asymmetric JWK) and the purpose of this PR is to handle both kinds of tokens, using legacy as fallback.
So it should work for any valid token... but I can do more testing to make sure if cron requests is working fine.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 18, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR replaces the jose import with jsr:@panva/jose, adds JWKS support and a JWKS endpoint derived from SUPABASE_URL, and implements hybrid JWT verification. verifyJWT was renamed to isValidLegacyJWT (HS256) and a new isValidJWT handles asymmetric verification using a lazy-loaded JWKS. A new exported verifyHybridJWT(jwtSecret, jwksUrl, jwt) routes verification by token alg (HS256 → legacy, ES256/RS256 → JWKS). ServeFunctions now calls ResolveJWKS at startup and sets SUPABASE_INTERNAL_JWKS in the environment.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant RequestHandler as Request Handler
    participant VerifyHybrid as verifyHybridJWT()
    participant AlgoCheck as Algorithm Checker
    participant LegacyPath as isValidLegacyJWT()
    participant JWKSLoader as JWKS Loader
    participant JWKSRemote as JWKS Endpoint

    Client->>RequestHandler: HTTP request with Authorization: Bearer <token>
    RequestHandler->>VerifyHybrid: verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token)
    VerifyHybrid->>AlgoCheck: read "alg" from JWT header
    alt alg == "HS256"
        AlgoCheck->>LegacyPath: verify with jwtSecret
        LegacyPath-->>VerifyHybrid: success/failure
    else alg == "ES256" or "RS256"
        AlgoCheck->>JWKSLoader: ensure jwks loaded (SUPABASE_INTERNAL_JWKS or fetch)
        JWKSLoader->>JWKSRemote: fetch JWKS (lazy, if needed)
        JWKSRemote-->>JWKSLoader: return keys
        JWKSLoader->>VerifyHybrid: provide JWK
        VerifyHybrid->>VerifyHybrid: verify using public key
        VerifyHybrid-->>RequestHandler: success/failure
    end
    VerifyHybrid-->>RequestHandler: verification outcome
Loading

Assessment against linked issues

Objective Addressed Explanation
Fix JWT key verification broken in Edge Runtime 1.7.0+ where ES256 verification fails with CryptoKey/Uint8Array type mismatch [#654]
Support both symmetric (HS256) and asymmetric (ES256/RS256) JWT verification paths for self-hosted deployments [#654]
Implement lazy-loading of JWKS from remote endpoint to avoid repeated fetches [#654]

Out-of-scope changes

Code Change Explanation
Early ResolveJWKS call and setting SUPABASE_INTERNAL_JWKS during startup (internal/functions/serve/serve.go) Exposes internal JWKS at startup and moves JWKS retrieval to initialization; this is not required by the verification-fix objective and alters startup behavior.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@FredrikWendt
Copy link
Copy Markdown

Looking forward to see this happen. Currently, we're lowering security on our apps to work around this issue. No action here in two weeks isn't impressive for a bug impacting security/auth.

@joelpramos
Copy link
Copy Markdown

joelpramos commented Mar 1, 2026

Hey @joelpramos

service_role...is using the old auth method

Yes, ANON_KEY and SERVICE_ROLE uses old auth (symmetric JWT) which can be verified from *JWT_SECRET

...fine for users but not for the service_role...

I don't think so, user's tokens is now being issued as new JWT (Asymmetric JWK) and the purpose of this PR is to handle both kinds of tokens, using legacy as fallback. So it should work for any valid token... but I can do more testing to make sure if cron requests is working fine.

@kallebysantos

So I guess what I am not yet 100% clear is what is now the flow for my Postgres Functions (pg_net) calling Edge Functions. I have tried new API Keys it doesn't appear like the Bearer token is "exchanged" into an actual JWT token (new or legacy), as it happens locally (self hosted). Also tried old tokens which obviously aren't active anymore.

Not sure if there's a different network route that it isn't sending it through Kong to 'transform' the Bearer token from secret API key into a valid JWT?

At the end of the day it's not clear yet to me what is the appropriate configuration for this set up... so that I can wrap these function calls with cron. Clearly behaviours diverge.

@kallebysantos
Copy link
Copy Markdown
Member Author

Hey guys!
Thanks for your feedbacks, we're working daily on this situation.
Hope to have a proper fix soon 🙏

@FredrikWendt: Looking forward to see this happen....No action here in two weeks isn't impressive for a bug impacting security/auth.

We do understand your disappointment 🥲
Even this PR not being active for some time, keep in mind that its only for local CLI. We're active working on internal changes to fix this situation at all environments.

@joelpramos: as it happens locally (self hosted)...

Unfortunately there's a different behaviour on how Local CLI is handling the sb_xxx keys, and I believe this gateway injection doesn't happen at Platform.

t's not clear yet to me... wrap these function calls with cron...

Its a good catch 👍
I still need to validate the best approach for it, then I can loop back to you with a recommended solution.

@kallebysantos
Copy link
Copy Markdown
Member Author

kallebysantos commented Mar 2, 2026

Hey @joelpramos 💚

While we're still working on a better official solution
I believe that should be possible to invoke from webhooks/cron with following the steps:

Using new API Keys to create webhooks:

  1. Create a new publishable/secret key at https://supabase.com/dashboard/project/[ref-id]/settings/api-keys
Details image
  1. Add it as custom secret like: SB_CRON_WEBHOOK
  2. Use the incoming apiKey to check if it matches your secret as well to initialise the createClient()
Details
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from 'npm:@supabase/supabase-js@2'

console.info('server started');

Deno.serve(async (req: Request) => {
  const apiKey = req.headers.get('apiKey')
  if (apiKey !== Deno.env.get('SB_CRON_WEBHOOK')!) {
    return new Response(null, { status: 401 });
  }

  // Use the request 'apiKey' to init supabase client
  const supabase = createClient(Deno.env.get('SUPABASE_URL')!, apiKey);

  // Do somenthing

  return new Response();
});
  1. Call it from your cron or webhook, sending the apiKey header. — You don't need JWT in this situation...

Allows verify new JWTs as well legacy
It helps to reduce latency for Legacy token verifications, since it
avoid unnecessary requests.
@kallebysantos kallebysantos force-pushed the fix-update-functions-verify-jwt branch from 4c30f0a to 00c30af Compare March 9, 2026 17:25
Comment thread internal/functions/serve/templates/main.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/functions/serve/templates/main.ts`:
- Around line 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.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 1d8a5b6b-102d-4cda-8222-657cbc10d71b

📥 Commits

Reviewing files that changed from the base of the PR and between 2258e81 and 4ab6860.

📒 Files selected for processing (1)
  • internal/functions/serve/templates/main.ts

Comment on lines +122 to +137
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);
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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/api/types.gen.go`:
- Around line 3400-3401: You added the generated field PresenceEnabled
(json:"presence_enabled") directly into pkg/api/types.gen.go which is
overwritten by codegen; instead add the presence_enabled property to the OpenAPI
overlay used by generation (update api/overlay.yaml or the source OpenAPI spec
referenced by pkg/api/types.cfg.yaml) so the field is part of the schema, then
run the oapi-codegen regeneration to produce an updated pkg/api/types.gen.go
(verify the PresenceEnabled field appears and remove your manual edit). Ensure
any corresponding comments/metadata match the added schema entry.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: d2b46ad7-4478-4fa6-bacf-d0fcf9c8678b

📥 Commits

Reviewing files that changed from the base of the PR and between 4ab6860 and 964f3bc.

📒 Files selected for processing (1)
  • pkg/api/types.gen.go

Comment thread pkg/api/types.gen.go
@avallete avallete merged commit 35867ca into supabase:develop Mar 10, 2026
10 of 11 checks passed
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.

JWT key verification broken (Edge Runtime 1.7.0+) for self-hosted deployments

7 participants