Skip to content

fix: add JWT signature verification using JWKS#1700

Closed
WcaleNieWolny wants to merge 4 commits into
mainfrom
fix/jwt-signature-verification
Closed

fix: add JWT signature verification using JWKS#1700
WcaleNieWolny wants to merge 4 commits into
mainfrom
fix/jwt-signature-verification

Conversation

@WcaleNieWolny
Copy link
Copy Markdown
Contributor

@WcaleNieWolny WcaleNieWolny commented Feb 25, 2026

Summary

  • Add verifyJWT function using jose library's createRemoteJWKSet + jwtVerify to cryptographically verify JWT signatures against the Supabase JWKS endpoint
  • Update middlewareAuth and middlewareV2 (via foundJWT) to use signature-verified claims instead of raw decode
  • Update all direct callers of getClaimsFromJWT in auth paths to use verifyJWT
  • Retain getClaimsFromJWT for non-auth reads with updated docstring warning
  • JWKS is cached per cold start so subsequent verifications are fast

Test plan

  • Verify existing auth flows work (JWT login, API key auth)
  • Verify RBAC-gated endpoints (role_bindings, groups) reject forged JWTs
  • Verify middlewareV2 endpoints (devices, stats) reject forged JWTs
  • Confirm JWKS caching works correctly across requests

Summary by CodeRabbit

  • Security & Performance
    • Stronger JWT verification using cryptographic validation and fallback handling for more robust authentication.
    • Added JWKS-backed token caching to reduce auth latency and improve API response times.
    • Improved authentication error detection and reporting for clearer, more reliable failure handling.

Strengthen JWT validation by verifying signatures against the Supabase
JWKS endpoint before trusting claims. Previously getClaimsFromJWT decoded
payloads locally without signature verification. A new verifyJWT function
uses jose's createRemoteJWKSet + jwtVerify to cryptographically verify
tokens. All auth middleware and direct callers are updated.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

Replaces synchronous JWT extraction with an asynchronous JWKS/HS256 verifier across auth handlers, introduces a cached JWKS loader and async verifyJWT(c, jwt), and expands JWTClaims with additional fields (role, exp, iat, aud).

Changes

Cohort / File(s) Summary
Auth handlers
supabase/functions/_backend/private/validate_password_compliance.ts, supabase/functions/_backend/private/verify_email_otp.ts, supabase/functions/_backend/public/replication.ts
Replaced synchronous getClaimsFromJWT(...) usage with awaited verifyJWT(c, authorization); imports updated to pull verifyJWT from .../utils/hono.ts.
Core JWT utilities
supabase/functions/_backend/utils/hono.ts
Added module-scoped JWKS cache and getJWKS(c). Replaced getClaimsFromJWT with async verifyJWT(c, jwt) using JWKS (jose.jwtVerify) with HS fallback, expanded JWTClaims (role, exp, iat, aud).
Middleware
supabase/functions/_backend/utils/hono_middleware.ts
Updated middleware to call await verifyJWT(c, jwt) for JWT validation; on invalid JWT records failed auth and returns 401. Import/export lists adjusted accordingly.

Sequence Diagram

sequenceDiagram
    participant Handler as HTTP Handler
    participant Middleware as middlewareAuth
    participant Verify as verifyJWT(context, jwt)
    participant Cache as JWKS Cache
    participant Remote as Supabase JWKS Endpoint
    participant JoseLib as jose.jwtVerify()

    Handler->>Middleware: receives Authorization header
    Middleware->>Verify: verifyJWT(c, jwt)
    Verify->>Cache: getJWKS(c)
    alt JWKS cached
        Cache-->>Verify: cached JWKS
    else JWKS not cached
        Cache->>Remote: fetch JWKS
        Remote-->>Cache: JWKS payload
        Cache-->>Verify: JWKS
    end
    Verify->>JoseLib: jwtVerify(jwt, jwks)
    alt JWKS verification succeeds
        JoseLib-->>Verify: decoded claims
        Verify-->>Middleware: JWTClaims
        Middleware-->>Handler: auth context set
    else JWKS verification fails
        JoseLib-->>Verify: error
        Verify->>Verify: try HS256 fallback (shared secret)
        alt HS256 succeeds
            Verify-->>Middleware: JWTClaims
            Middleware-->>Handler: auth context set
        else HS256 fails
            Verify-->>Middleware: null
            Middleware-->>Handler: 401 invalid_jwt
        end
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

Suggested Labels

💰 Rewarded

Poem

🐰
JWKS in my pocket, keys shining bright,
Async hops through the token-night,
Claims verified with jose's art,
Cached and steady, a rabbit's heart,
Securely bound, each auth takes flight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: add JWT signature verification using JWKS' clearly and concisely summarizes the main change: implementing JWT signature verification using JWKS, which is the core objective across all modified files.
Description check ✅ Passed The PR description covers the main objectives (verifyJWT implementation, caching, fallback mechanism) and includes a test plan section with specific items to verify, though test items are not yet completed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/jwt-signature-verification

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.

All auth paths now use verifyJWT with JWKS-based signature
verification, so the decode-only helper is no longer needed.
@WcaleNieWolny WcaleNieWolny marked this pull request as ready for review February 25, 2026 16:24
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Copy link
Copy Markdown
Contributor

@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

🧹 Nitpick comments (2)
supabase/functions/_backend/utils/hono.ts (1)

59-60: Log verification failures instead of silently collapsing all errors to null.

The current catch { return null } makes invalid-token errors indistinguishable from JWKS fetch/config failures during incidents.

🛠️ Proposed observability improvement
-  catch {
+  catch (error) {
+    cloudlog({
+      requestId: c.get('requestId'),
+      message: 'jwt_verification_failed',
+      error: error instanceof Error ? error.message : String(error),
+    })
     return null
   }

As per coding guidelines "Use structured logging with cloudlog({ requestId: c.get('requestId'), message: '...' }) for all backend logging".

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

In `@supabase/functions/_backend/utils/hono.ts` around lines 59 - 60, Replace the
silent catch in the token verification path (the catch { return null } block)
with a structured log: change it to catch (err) { cloudlog({ requestId:
c.get('requestId'), message: 'Token verification failure', error: err, context:
'verifyJwt / JWKS fetch' }); return null; } so verification errors (invalid
token vs JWKS/config failures) are recorded using cloudlog and include the error
and requestId for observability; keep the return null behavior unchanged.
supabase/functions/_backend/private/verify_email_otp.ts (1)

50-51: Avoid double-verifying the same JWT in this request.

middlewareAuth already verifies this token. Re-verifying here adds redundant JWKS work and a second failure point; consider storing verified claims in context and reusing them.

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

In `@supabase/functions/_backend/private/verify_email_otp.ts` around lines 50 -
51, middlewareAuth already verifies the JWT, so remove the redundant verifyJWT
call in verify_email_otp.ts and read the verified claims populated by
middlewareAuth (e.g., c.authClaims or the field middlewareAuth sets on context)
instead of calling verifyJWT(c, authorization); replace "const claims = await
verifyJWT(c, authorization); const email = claims?.email" with code that reads
the claims/email from the context (and throw a clear error if that field is
missing), and remove any now-unused imports or variables related to
verifyJWT/authorization.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@supabase/functions/_backend/utils/hono.ts`:
- Around line 55-57: The verification currently only checks signature; update
the jwtVerify call in the block that creates jwks via getJWKS(c) to pass
explicit claim constraints (issuer and audience) so tokens are also validated
for intended issuer/audience; retrieve the expected values from your
configuration/context (e.g., from c.env or your app config) and call
jwtVerify(token, jwks, { issuer: expectedIssuer, audience: expectedAudience }),
then return the payload as JWTClaims as before.

---

Nitpick comments:
In `@supabase/functions/_backend/private/verify_email_otp.ts`:
- Around line 50-51: middlewareAuth already verifies the JWT, so remove the
redundant verifyJWT call in verify_email_otp.ts and read the verified claims
populated by middlewareAuth (e.g., c.authClaims or the field middlewareAuth sets
on context) instead of calling verifyJWT(c, authorization); replace "const
claims = await verifyJWT(c, authorization); const email = claims?.email" with
code that reads the claims/email from the context (and throw a clear error if
that field is missing), and remove any now-unused imports or variables related
to verifyJWT/authorization.

In `@supabase/functions/_backend/utils/hono.ts`:
- Around line 59-60: Replace the silent catch in the token verification path
(the catch { return null } block) with a structured log: change it to catch
(err) { cloudlog({ requestId: c.get('requestId'), message: 'Token verification
failure', error: err, context: 'verifyJwt / JWKS fetch' }); return null; } so
verification errors (invalid token vs JWKS/config failures) are recorded using
cloudlog and include the error and requestId for observability; keep the return
null behavior unchanged.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6de8c0d and c0b712d.

📒 Files selected for processing (5)
  • supabase/functions/_backend/private/validate_password_compliance.ts
  • supabase/functions/_backend/private/verify_email_otp.ts
  • supabase/functions/_backend/public/replication.ts
  • supabase/functions/_backend/utils/hono.ts
  • supabase/functions/_backend/utils/hono_middleware.ts

Comment thread supabase/functions/_backend/utils/hono.ts
Constrain jwtVerify to only accept tokens issued by our Supabase
auth endpoint with the 'authenticated' audience claim.
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@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 `@supabase/functions/_backend/utils/hono.ts`:
- Around line 70-80: The function getSharedJwtSecret currently returns the
public LOCAL_SUPABASE_DEFAULT_JWT_SECRET solely based on hostname checks
(localhost/127.0.0.1/kong); change it to only return that default when an
explicit local-mode env flag is set (e.g., require getEnv(c,
'<LOCAL_MODE_FLAG>') === 'true' such as SUPABASE_LOCAL or
SUPABASE_DISABLE_JWT_PROTECTION), otherwise do not fall back to
LOCAL_SUPABASE_DEFAULT_JWT_SECRET. Update both places where this fallback occurs
in getSharedJwtSecret to first check the explicit local-mode config (using
getEnv with the chosen flag) before returning LOCAL_SUPABASE_DEFAULT_JWT_SECRET,
preserving existing behavior when SUPABASE_JWT_SECRET or JWT_SECRET are
provided.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c0b712d and 26cbfe7.

📒 Files selected for processing (1)
  • supabase/functions/_backend/utils/hono.ts

Comment on lines +70 to +80
function getSharedJwtSecret(c: Context<MiddlewareKeyVariables>, rawSupabaseUrl: string): string {
const configuredSecret = getEnv(c, 'SUPABASE_JWT_SECRET') || getEnv(c, 'JWT_SECRET')
if (configuredSecret)
return configuredSecret

try {
const normalized = normalizeSupabaseBaseUrl(rawSupabaseUrl)
const host = new URL(normalized).hostname
if (host === '127.0.0.1' || host === 'localhost' || host === 'kong')
return LOCAL_SUPABASE_DEFAULT_JWT_SECRET
}
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.

⚠️ Potential issue | 🟠 Major

Gate local default JWT secret behind explicit local-mode config.

The known default secret is enabled from hostname checks alone (localhost/127.0.0.1/kong). If SUPABASE_URL is accidentally set to one of these in a non-local deployment, forged HS tokens signed with the public default secret can pass verification.

Proposed fix
 function getSharedJwtSecret(c: Context<MiddlewareKeyVariables>, rawSupabaseUrl: string): string {
   const configuredSecret = getEnv(c, 'SUPABASE_JWT_SECRET') || getEnv(c, 'JWT_SECRET')
   if (configuredSecret)
     return configuredSecret

+  const envName = (getEnv(c, 'ENV_NAME') || '').toLowerCase()
+  const allowLocalFallback
+    = getEnv(c, 'ALLOW_LOCAL_SUPABASE_JWT_FALLBACK') === 'true'
+      || envName === 'local'
+      || envName === 'development'
+      || envName === 'dev'
+
   try {
     const normalized = normalizeSupabaseBaseUrl(rawSupabaseUrl)
     const host = new URL(normalized).hostname
-    if (host === '127.0.0.1' || host === 'localhost' || host === 'kong')
+    if (allowLocalFallback && (host === '127.0.0.1' || host === 'localhost' || host === 'kong'))
       return LOCAL_SUPABASE_DEFAULT_JWT_SECRET
   }
   catch {
   }

   return ''
 }

Also applies to: 124-131

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

In `@supabase/functions/_backend/utils/hono.ts` around lines 70 - 80, The function
getSharedJwtSecret currently returns the public
LOCAL_SUPABASE_DEFAULT_JWT_SECRET solely based on hostname checks
(localhost/127.0.0.1/kong); change it to only return that default when an
explicit local-mode env flag is set (e.g., require getEnv(c,
'<LOCAL_MODE_FLAG>') === 'true' such as SUPABASE_LOCAL or
SUPABASE_DISABLE_JWT_PROTECTION), otherwise do not fall back to
LOCAL_SUPABASE_DEFAULT_JWT_SECRET. Update both places where this fallback occurs
in getSharedJwtSecret to first check the explicit local-mode config (using
getEnv with the chosen flag) before returning LOCAL_SUPABASE_DEFAULT_JWT_SECRET,
preserving existing behavior when SUPABASE_JWT_SECRET or JWT_SECRET are
provided.

@riderx riderx deleted the fix/jwt-signature-verification branch March 17, 2026 14:51
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.

1 participant