Skip to content

Conversation

nikosdouvlis
Copy link
Member

@nikosdouvlis nikosdouvlis commented Oct 15, 2025

Description

Fixed JWT public key caching in verifyToken() to support multi-instance scenarios. Public keys are now correctly cached per kid from the token header instead of using a single shared cache key.

  • Changed loadClerkJWKFromLocal to loadClerkJwkFromPem for loading keys in PEM format.
  • Updated key loading logic in verifyHandshakeToken and verifyToken functions.
  • Enhanced caching mechanism to avoid collisions with remote keys by prefixing local keys with "local-".

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

Summary by CodeRabbit

  • Bug Fixes

    • Fixed intermittent token verification failures in multi-instance deployments by isolating JWT key caching per key identifier.
    • Improved reliability when different public keys are used across instances.
  • Refactor

    • Consolidated key loading to consistently accept PEM-formatted keys.
    • Improved TypeScript typings for clearer verification option shapes.
  • Tests

    • Added tests covering cache isolation, repeated lookups, and mixed-key scenarios.

- Changed `loadClerkJWKFromLocal` to `loadClerkJwkFromPem` for loading keys in PEM format.
- Updated key loading logic in `verifyHandshakeToken` and `verifyToken` functions.
- Enhanced caching mechanism to avoid collisions with remote keys by prefixing local keys with "local-".
Copy link

changeset-bot bot commented Oct 15, 2025

🦋 Changeset detected

Latest commit: 18fdacd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 19 packages
Name Type
@clerk/backend Patch
@clerk/shared Patch
@clerk/agent-toolkit Patch
@clerk/astro Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/nextjs Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/remix Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch
@clerk/chrome-extension Patch
@clerk/clerk-js Patch
@clerk/elements Patch
@clerk/expo-passkeys Patch
@clerk/clerk-expo Patch
@clerk/clerk-react Patch
@clerk/vue Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link

vercel bot commented Oct 15, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
clerk-js-sandbox Ready Ready Preview Comment Oct 15, 2025 3:07pm

Copy link
Contributor

coderabbitai bot commented Oct 15, 2025

Walkthrough

Updates JWT key loading and caching to use kid-scoped PEM-based keys and per-kid cache entries; replaces the local JWK loader with a PEM loader, updates verification and handshake flows, adjusts tests and fixtures, and adds a Simplify type re-export.

Changes

Cohort / File(s) Summary
JWT key loading & verify
packages/backend/src/tokens/keys.ts, packages/backend/src/tokens/verify.ts
Replaced local JWK loader with loadClerkJwkFromPem({ kid, pem }); cache keys are now per-kid (PEM entries prefixed local-). Remote JWKS loader accepts params object and caches each key by its own kid. verifyToken updated to use PEM loader, explicit JsonWebKey, and Simplify type.
Handshake
packages/backend/src/tokens/handshake.ts
Switched from loadClerkJWKFromLocal(jwtKey) to loadClerkJwkFromPem({ kid, pem: jwtKey }) and adjusted verify call to pass key.
Tests
packages/backend/src/tokens/__tests__/keys.test.ts
Replaced local key loader usage with PEM-based loader; added tests for per-kid caching, cache identity, local- prefix collision avoidance, and distinct entries when kid differs. Added MOCK_KID.
Fixtures
packages/backend/src/fixtures/index.ts
Updated exported mockPEMJwk.kid from 'local' to 'local-test-kid'.
Shared types
packages/shared/src/types/index.ts, packages/shared/src/types/utils.ts
Added and exported Simplify<T> utility type; removed _unstable_mock_type export.
Changeset
.changeset/fix-multi-instance-jwt-caching.md
Documented switch to kid-scoped JWT key caching for multi-instance deployments; no public signatures changed.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Client
  participant Backend as verifyToken()
  participant Keys as keys.ts
  participant Cache as JWK Cache
  participant JWKS as Remote JWKS
  participant JWT as verifyJwt()

  Client->>Backend: verifyToken(token, { jwtKey?, kid, ... })
  alt jwtKey (PEM) provided
    Backend->>Keys: loadClerkJwkFromPem({ kid, pem })
    Keys->>Cache: get("local-" + kid)
    alt Cache hit
      Cache-->>Keys: JsonWebKey
    else Cache miss
      Keys->>Keys: parse PEM → build JWK (kid: "local-" + kid)
      Keys->>Cache: set("local-" + kid, JWK)
    end
    Keys-->>Backend: JsonWebKey
  else No PEM provided
    Backend->>Keys: loadClerkJWKFromRemote({ ..., kid })
    Keys->>Cache: get(kid)
    alt Cache hit
      Cache-->>Keys: JsonWebKey
    else Cache miss
      Keys->>JWKS: fetch JWKS
      JWKS-->>Keys: { keys[] }
      loop for each key
        Keys->>Cache: set(key.kid, key)
      end
      Keys->>Cache: get(kid)
    end
    Keys-->>Backend: JsonWebKey
  end
  Backend->>JWT: verifyJwt(token, { key })
  JWT-->>Backend: verification result
  Backend-->>Client: result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

I stash each key by kid, neat and tight,
In burrows of cache, labeled just right.
From PEM I nibble, from JWKS I glean,
No more collisions in the meadow green.
Thump-thump! Multi-burrow sync at last. 🐇🔑

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title clearly and concisely describes the primary backend change, specifically fixing the JWK cache to support multiple local keys per kid, which aligns with the pull request’s objectives and main implementation updates.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch nikos/load-local-pem-cache

📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2299669 and 18fdacd.

📒 Files selected for processing (1)
  • packages/backend/src/tokens/handshake.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/backend/src/tokens/handshake.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: semgrep-cloud-platform/scan
  • GitHub Check: semgrep-cloud-platform/scan

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

Copy link
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: 0

🧹 Nitpick comments (1)
packages/backend/src/tokens/keys.ts (1)

52-86: Add explicit note about local- prefix in JSDoc

Enhance the existing JSDoc on loadClerkJwkFromPem to mention that the returned JWK’s kid is prefixed with "local-" to avoid cache collisions:

/**
 * Loads a local PEM key usually from process.env and transforms it to JsonWebKey format.
 * The result is cached on the module level to avoid unnecessary computations in subsequent invocations.
+ *
+ * Note: the returned JWK’s `kid` will be prefixed with "local-".  
+ * For example, passing `kid: "test"` yields `kid: "local-test"`.
 */
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1236c74 and 2299669.

📒 Files selected for processing (8)
  • .changeset/fix-multi-instance-jwt-caching.md (1 hunks)
  • packages/backend/src/fixtures/index.ts (1 hunks)
  • packages/backend/src/tokens/__tests__/keys.test.ts (2 hunks)
  • packages/backend/src/tokens/handshake.ts (3 hunks)
  • packages/backend/src/tokens/keys.ts (3 hunks)
  • packages/backend/src/tokens/verify.ts (3 hunks)
  • packages/shared/src/types/index.ts (1 hunks)
  • packages/shared/src/types/utils.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (11)
.changeset/**

📄 CodeRabbit inference engine (.cursor/rules/monorepo.mdc)

Automated releases must use Changesets.

Files:

  • .changeset/fix-multi-instance-jwt-caching.md
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/development.mdc)

**/*.{js,jsx,ts,tsx}: All code must pass ESLint checks with the project's configuration
Follow established naming conventions (PascalCase for components, camelCase for variables)
Maintain comprehensive JSDoc comments for public APIs
Use dynamic imports for optional features
All public APIs must be documented with JSDoc
Provide meaningful error messages to developers
Include error recovery suggestions where applicable
Log errors appropriately for debugging
Lazy load components and features when possible
Implement proper caching strategies
Use efficient data structures and algorithms
Profile and optimize critical paths
Validate all inputs and sanitize outputs
Implement proper logging with different levels

Files:

  • packages/backend/src/fixtures/index.ts
  • packages/backend/src/tokens/__tests__/keys.test.ts
  • packages/shared/src/types/index.ts
  • packages/backend/src/tokens/handshake.ts
  • packages/shared/src/types/utils.ts
  • packages/backend/src/tokens/verify.ts
  • packages/backend/src/tokens/keys.ts
**/*.{js,jsx,ts,tsx,json,css,scss,md,yaml,yml}

📄 CodeRabbit inference engine (.cursor/rules/development.mdc)

Use Prettier for consistent code formatting

Files:

  • packages/backend/src/fixtures/index.ts
  • packages/backend/src/tokens/__tests__/keys.test.ts
  • packages/shared/src/types/index.ts
  • packages/backend/src/tokens/handshake.ts
  • packages/shared/src/types/utils.ts
  • packages/backend/src/tokens/verify.ts
  • packages/backend/src/tokens/keys.ts
packages/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/development.mdc)

TypeScript is required for all packages

Files:

  • packages/backend/src/fixtures/index.ts
  • packages/backend/src/tokens/__tests__/keys.test.ts
  • packages/shared/src/types/index.ts
  • packages/backend/src/tokens/handshake.ts
  • packages/shared/src/types/utils.ts
  • packages/backend/src/tokens/verify.ts
  • packages/backend/src/tokens/keys.ts
packages/**/*.{ts,tsx,d.ts}

📄 CodeRabbit inference engine (.cursor/rules/development.mdc)

Packages should export TypeScript types alongside runtime code

Files:

  • packages/backend/src/fixtures/index.ts
  • packages/backend/src/tokens/__tests__/keys.test.ts
  • packages/shared/src/types/index.ts
  • packages/backend/src/tokens/handshake.ts
  • packages/shared/src/types/utils.ts
  • packages/backend/src/tokens/verify.ts
  • packages/backend/src/tokens/keys.ts
packages/**/index.{js,ts}

📄 CodeRabbit inference engine (.cursor/rules/development.mdc)

Use tree-shaking friendly exports

Files:

  • packages/backend/src/fixtures/index.ts
  • packages/shared/src/types/index.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/development.mdc)

Use proper TypeScript error types

**/*.{ts,tsx}: Always define explicit return types for functions, especially public APIs
Use proper type annotations for variables and parameters where inference isn't clear
Avoid any type - prefer unknown when type is uncertain, then narrow with type guards
Use interface for object shapes that might be extended
Use type for unions, primitives, and computed types
Prefer readonly properties for immutable data structures
Use private for internal implementation details
Use protected for inheritance hierarchies
Use public explicitly for clarity in public APIs
Prefer readonly for properties that shouldn't change after construction
Prefer composition and interfaces over deep inheritance chains
Use mixins for shared behavior across unrelated classes
Implement dependency injection for loose coupling
Let TypeScript infer when types are obvious
Use const assertions for literal types: as const
Use satisfies operator for type checking without widening
Use mapped types for transforming object types
Use conditional types for type-level logic
Leverage template literal types for string manipulation
Use ES6 imports/exports consistently
Use default exports sparingly, prefer named exports
Use type-only imports: import type { ... } from ...
No any types without justification
Proper error handling with typed errors
Consistent use of readonly for immutable data
Proper generic constraints
No unused type parameters
Proper use of utility types instead of manual type construction
Type-only imports where possible
Proper tree-shaking friendly exports
No circular dependencies
Efficient type computations (avoid deep recursion)

Files:

  • packages/backend/src/fixtures/index.ts
  • packages/backend/src/tokens/__tests__/keys.test.ts
  • packages/shared/src/types/index.ts
  • packages/backend/src/tokens/handshake.ts
  • packages/shared/src/types/utils.ts
  • packages/backend/src/tokens/verify.ts
  • packages/backend/src/tokens/keys.ts
**/*.{js,ts,tsx,jsx}

📄 CodeRabbit inference engine (.cursor/rules/monorepo.mdc)

Support multiple Clerk environment variables (CLERK_, NEXT_PUBLIC_CLERK_, etc.) for configuration.

Files:

  • packages/backend/src/fixtures/index.ts
  • packages/backend/src/tokens/__tests__/keys.test.ts
  • packages/shared/src/types/index.ts
  • packages/backend/src/tokens/handshake.ts
  • packages/shared/src/types/utils.ts
  • packages/backend/src/tokens/verify.ts
  • packages/backend/src/tokens/keys.ts
**/index.ts

📄 CodeRabbit inference engine (.cursor/rules/react.mdc)

Use index.ts files for clean imports but avoid deep barrel exports

Avoid barrel files (index.ts re-exports) as they can cause circular dependencies

Files:

  • packages/backend/src/fixtures/index.ts
  • packages/shared/src/types/index.ts
packages/**/*.{test,spec}.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/monorepo.mdc)

Unit tests should use Jest or Vitest as the test runner.

Files:

  • packages/backend/src/tokens/__tests__/keys.test.ts
**/__tests__/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/typescript.mdc)

**/__tests__/**/*.{ts,tsx}: Create type-safe test builders/factories
Use branded types for test isolation
Implement proper mock types that match interfaces

Files:

  • packages/backend/src/tokens/__tests__/keys.test.ts
🧬 Code graph analysis (4)
packages/backend/src/tokens/__tests__/keys.test.ts (2)
packages/backend/src/tokens/keys.ts (1)
  • loadClerkJwkFromPem (52-86)
packages/backend/src/fixtures/index.ts (3)
  • mockPEMKey (51-52)
  • mockPEMJwk (54-60)
  • mockPEMJwtKey (62-71)
packages/backend/src/tokens/handshake.ts (1)
packages/backend/src/tokens/keys.ts (1)
  • loadClerkJwkFromPem (52-86)
packages/backend/src/tokens/verify.ts (3)
packages/shared/src/types/utils.ts (1)
  • Simplify (5-7)
packages/backend/src/jwt/verifyJwt.ts (1)
  • VerifyJwtOptions (100-122)
packages/backend/src/tokens/keys.ts (2)
  • LoadClerkJWKFromRemoteOptions (91-118)
  • loadClerkJwkFromPem (52-86)
packages/backend/src/tokens/keys.ts (2)
packages/backend/src/errors.ts (1)
  • TokenVerificationError (41-69)
packages/backend/src/constants.ts (2)
  • API_URL (1-1)
  • API_VERSION (2-2)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: semgrep-cloud-platform/scan
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: semgrep-cloud-platform/scan
🔇 Additional comments (12)
packages/shared/src/types/utils.ts (1)

1-7: LGTM! Clean type utility with proper attribution.

The Simplify type is a well-established pattern for flattening intersection types to improve IDE hints. The reference to type-fest provides helpful context.

.changeset/fix-multi-instance-jwt-caching.md (1)

1-14: LGTM! Clear documentation of the fix.

The changeset accurately describes both the problem (cache collision in multi-instance scenarios) and the solution (per-kid caching). This will help users understand the impact of the change.

packages/backend/src/tokens/keys.ts (2)

33-36: LGTM! Cache key parameterization enables per-kid caching.

The addition of the cacheKey parameter provides the flexibility needed to implement per-kid caching, which is the core fix for the multi-instance issue.


131-173: LGTM! Per-kid caching in remote JWKS correctly fixes the multi-instance bug.

Line 153's change from caching all keys under a single entry to caching each key by its own kid is the core fix. This ensures that multiple instances with different JWKS won't collide in the cache.

packages/backend/src/fixtures/index.ts (1)

54-60: LGTM! Fixture correctly reflects the prefixed kid output.

The change from kid: 'local' to kid: 'local-test-kid' aligns with the new behavior where loadClerkJwkFromPem returns a JWK with the "local-" prefix. This fixture represents the expected output of the loader function.

packages/shared/src/types/index.ts (1)

1-1: LGTM! Clean type re-export following best practices.

The use of export type { ... } correctly indicates this is a type-only export, enabling proper tree-shaking and avoiding runtime overhead.

packages/backend/src/tokens/handshake.ts (2)

10-10: LGTM! Import updated to use PEM-based loader.

The import change from loadClerkJWKFromLocal to loadClerkJwkFromPem aligns with the renamed and refactored key loading API.


68-72: LGTM! Handshake flow correctly uses the new PEM-based loader.

The call to loadClerkJwkFromPem({ kid, pem: jwtKey }) properly passes:

  • The kid from the token header (line 64) for cache key construction
  • The jwtKey as the pem parameter for key material

This maintains the same functionality while enabling per-kid caching.

packages/backend/src/tokens/__tests__/keys.test.ts (2)

15-17: LGTM! Import and constant updates align with the new API.

The import change to loadClerkJwkFromPem and the addition of the MOCK_KID constant improve test clarity and consistency.


40-81: Excellent test coverage for per-kid caching behavior!

These new tests thoroughly validate the core fix:

  1. Lines 40-55: Verifies separate cache entries for different kids with different PEMs
  2. Lines 57-62: Confirms caching works (same object reference returned)
  3. Lines 64-67: Validates the "local-" prefix to prevent collisions
  4. Lines 69-81: Tests the edge case where the same PEM is used with different kids

This comprehensive coverage ensures the multi-instance caching fix works correctly and prevents regressions.

packages/backend/src/tokens/verify.ts (2)

2-2: LGTM! Simplify wrapper improves type ergonomics.

Wrapping VerifyTokenOptions with Simplify flattens the intersection type, making IDE hints more readable without changing the runtime behavior or type semantics. This is a recommended practice for complex composed types.

Also applies to: 26-34


127-134: LGTM! Token verification correctly uses the new PEM-based loader.

The changes integrate cleanly with the new caching strategy:

  • Line 127: Explicit JsonWebKey type annotation improves type safety
  • Line 130: loadClerkJwkFromPem({ kid, pem: options.jwtKey }) properly passes the kid from the token header for per-kid caching
  • Line 133: Remote path unchanged, maintaining backward compatibility

Copy link

pkg-pr-new bot commented Oct 15, 2025

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@6993

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@6993

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@6993

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@6993

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@6993

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@6993

@clerk/elements

npm i https://pkg.pr.new/@clerk/elements@6993

@clerk/clerk-expo

npm i https://pkg.pr.new/@clerk/clerk-expo@6993

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@6993

@clerk/express

npm i https://pkg.pr.new/@clerk/express@6993

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@6993

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@6993

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@6993

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@6993

@clerk/clerk-react

npm i https://pkg.pr.new/@clerk/clerk-react@6993

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@6993

@clerk/remix

npm i https://pkg.pr.new/@clerk/remix@6993

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@6993

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@6993

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@6993

@clerk/themes

npm i https://pkg.pr.new/@clerk/themes@6993

@clerk/types

npm i https://pkg.pr.new/@clerk/types@6993

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@6993

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@6993

commit: 18fdacd

@nikosdouvlis nikosdouvlis requested a review from a team October 15, 2025 14:59
Co-authored-by: panteliselef <panteliselef@outlook.com>
@nikosdouvlis nikosdouvlis merged commit 305f4ee into main Oct 15, 2025
41 checks passed
@nikosdouvlis nikosdouvlis deleted the nikos/load-local-pem-cache branch October 15, 2025 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants