Skip to content

feat(backend,nextjs): Add support for M2M JWT creation and verification#7955

Open
wobsoriano wants to merge 2 commits intomainfrom
rob/m2m-jwts-core3
Open

feat(backend,nextjs): Add support for M2M JWT creation and verification#7955
wobsoriano wants to merge 2 commits intomainfrom
rob/m2m-jwts-core3

Conversation

@wobsoriano
Copy link
Member

@wobsoriano wobsoriano commented Feb 28, 2026

Description

Cherry-picked from #7883

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

  • New Features

    • Added JWT-format support for machine-to-machine (M2M) tokens with local verification and custom claims.
  • Bug Fixes / Behavior

    • Endpoints now reject machine tokens when only session tokens are accepted, preventing token-type misclassification.
    • Improved JWT-based machine token detection and routing for verification.
  • Tests

    • Expanded test coverage for M2M creation, JWT verification, routing, and edge/error cases.

@changeset-bot
Copy link

changeset-bot bot commented Feb 28, 2026

🦋 Changeset detected

Latest commit: 13609a6

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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

@vercel
Copy link

vercel bot commented Feb 28, 2026

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

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Feb 28, 2026 2:10am

Request Review

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 28, 2026

Open in StackBlitz

@clerk/agent-toolkit

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

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/dev-cli

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@7955

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7955

@clerk/upgrade

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

@clerk/vue

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

commit: 13609a6

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

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

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 50e64c0 and 13609a6.

📒 Files selected for processing (1)
  • .changeset/clever-ways-raise.md
✅ Files skipped from review due to trivial changes (1)
  • .changeset/clever-ways-raise.md

📝 Walkthrough

Walkthrough

Adds JWT-format support for M2M tokens alongside opaque tokens: new M2MTokenFormat, token creation now accepts tokenFormat, and M2MToken.fromJwtPayload constructs tokens from JWT claims. Introduces local JWT verification utilities (verifyMachineJwt.ts) and routes verification via verifyM2MJwt/verifyOAuthJwt. Token detection expanded with isM2MJwt and isMachineJwt. Backend factory and M2MTokenApi constructor signatures updated to accept JWT-related options. Removes two internal exports, changes M2M id prefixes from m2m_ to mt_, tightens session-only endpoint handling for machine tokens, and adds extensive tests.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding M2M JWT creation and verification support across backend and nextjs packages.

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


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: 2

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

Inline comments:
In `@packages/backend/src/jwt/verifyMachineJwt.ts`:
- Line 89: The JWT header's `kid` must be validated before calling
resolveKeyAndVerifyJwt to avoid using undefined keys; in both verifyM2MJwt and
verifyOAuthJwt check that decoded.header.kid is a non-empty string and
throw/return a clear error (e.g., "missing JWT kid in header") if absent,
preventing downstream cache lookups like
loadClerkJwkFromPem/loadClerkJWKFromRemote from receiving `undefined`; update
the call sites that currently pass decoded.header.kid to first validate and fail
fast with a descriptive error when the kid is missing.

In `@packages/backend/src/tokens/verify.ts`:
- Around line 264-266: The code calls decodedResult.payload.sub.startsWith(...)
which can throw if sub is undefined or not a string; update the conditional in
the verify flow (the branch that currently checks M2M_SUBJECT_PREFIX) to first
ensure typeof decodedResult.payload.sub === 'string' (or use the existing
isM2MJwt type guard from machine.ts) before calling startsWith, and only call
verifyM2MJwt(token, decodedResult, options) when that guard passes; otherwise
fall through to the non-M2M verification path.

ℹ️ Review info

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

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 21c96e8 and 50e64c0.

📒 Files selected for processing (25)
  • .changeset/clever-ways-raise.md
  • integration/tests/machine-auth/m2m.test.ts
  • packages/backend/src/__tests__/exports.test.ts
  • packages/backend/src/api/__tests__/M2MTokenApi.test.ts
  • packages/backend/src/api/endpoints/M2MTokenApi.ts
  • packages/backend/src/api/factory.ts
  • packages/backend/src/api/resources/M2MToken.ts
  • packages/backend/src/api/resources/__tests__/M2MToken.test.ts
  • packages/backend/src/errors.ts
  • packages/backend/src/fixtures/index.ts
  • packages/backend/src/fixtures/machine.ts
  • packages/backend/src/internal.ts
  • packages/backend/src/jwt/verifyMachineJwt.ts
  • packages/backend/src/tokens/__tests__/authObjects.test.ts
  • packages/backend/src/tokens/__tests__/machine.test.ts
  • packages/backend/src/tokens/__tests__/verify.test.ts
  • packages/backend/src/tokens/machine.ts
  • packages/backend/src/tokens/request.ts
  • packages/backend/src/tokens/verify.ts
  • packages/express/src/__tests__/getAuth.test.ts
  • packages/fastify/src/__tests__/getAuth.test.ts
  • packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
  • packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts
  • packages/nextjs/src/server/data/getAuthDataFromRequest.ts
  • packages/nextjs/src/server/protect.ts
💤 Files with no reviewable changes (1)
  • packages/backend/src/tests/exports.test.ts

decoded: Jwt,
options: JwtMachineVerifyOptions,
): Promise<MachineTokenReturnType<M2MToken, MachineTokenVerificationError>> {
const result = await resolveKeyAndVerifyJwt(token, decoded.header.kid, options);
Copy link
Contributor

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:

# First, let's read the verifyMachineJwt.ts file to understand the context
wc -l packages/backend/src/jwt/verifyMachineJwt.ts

Repository: clerk/javascript

Length of output: 108


🏁 Script executed:

# Read the file with line numbers to see the context around line 89
cat -n packages/backend/src/jwt/verifyMachineJwt.ts

Repository: clerk/javascript

Length of output: 4983


🏁 Script executed:

# Find the key loading functions to check how they handle kid
fd -t f -e ts -e js . packages/backend/src | xargs rg -l 'loadClerkJwkFromPem|loadClerkJWKFromRemote' | head -5

Repository: clerk/javascript

Length of output: 281


🏁 Script executed:

# Let's check the keys.ts file to see how these functions handle the kid parameter
cat -n packages/backend/src/tokens/keys.ts | head -80

Repository: clerk/javascript

Length of output: 2823


🏁 Script executed:

# Check if there's more in keys.ts
wc -l packages/backend/src/tokens/keys.ts

Repository: clerk/javascript

Length of output: 99


🏁 Script executed:

# Get the function signatures and how they use kid
rg -n -A15 'function loadClerkJwkFromPem|function loadClerkJWKFromRemote' packages/backend/src/tokens/keys.ts

Repository: clerk/javascript

Length of output: 1506


🏁 Script executed:

# Check the Jwt type definition to see if kid is optional or required
rg -n 'type Jwt\s*=|interface Jwt' --type ts

Repository: clerk/javascript

Length of output: 294


🏁 Script executed:

# Check the Jwt and JwtHeader type definitions
rg -n -A10 'export interface Jwt\b|export interface JwtHeader' packages/shared/src/types/jwtv2.ts

Repository: clerk/javascript

Length of output: 571


🏁 Script executed:

# Also check how verifyJwt decodes the JWT to understand if kid would be present
rg -n -B5 -A20 'decode.*kid|header\.kid' packages/backend/src/tokens/ -t ts

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Check what decodes the JWT and sets the kid in the header
rg -n 'function verifyJwt|export.*verifyJwt' packages/backend/src/tokens/verify.ts | head -5

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Let's see the verifyJwt implementation
cat -n packages/backend/src/tokens/verify.ts | head -100

Repository: clerk/javascript

Length of output: 5467


🏁 Script executed:

# Let's check where the decoded JWT comes from in verifyM2MJwt and verifyOAuthJwt
rg -n -B10 'verifyM2MJwt|verifyOAuthJwt' packages/backend/src/tokens/verify.ts | head -40

Repository: clerk/javascript

Length of output: 1202


🏁 Script executed:

# Check if there's validation of the decoded JWT before passing to verifyM2MJwt
rg -n -B15 'verifyM2MJwt\|verifyOAuthJwt' packages/backend/src/tokens/verify.ts | grep -A15 -B15 'decodeJwt\|verifyM2MJwt'

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Let's look at the full context where verifyM2MJwt is called
sed -n '240,275p' packages/backend/src/tokens/verify.ts

Repository: clerk/javascript

Length of output: 1232


🏁 Script executed:

# Check the decodeJwt function to see what it returns
rg -n -A30 'export function decodeJwt' packages/backend/src/jwt/verifyJwt.ts

Repository: clerk/javascript

Length of output: 1517


🏁 Script executed:

# Continue reading decodeJwt to see how it parses and validates the header
sed -n '75,130p' packages/backend/src/jwt/verifyJwt.ts

Repository: clerk/javascript

Length of output: 1699


🏁 Script executed:

# Check if there's any validation that kid must be present in JwtHeader
rg -n 'kid' packages/backend/src/jwt/verifyJwt.ts

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Let's check if there's any validation of the header.kid before calling verifyM2MJwt or verifyOAuthJwt
sed -n '240,280p' packages/backend/src/tokens/verify.ts

Repository: clerk/javascript

Length of output: 1564


🏁 Script executed:

# Check the complete flow - does decodeJwt or verifyMachineAuthToken validate kid?
sed -n '240,280p' packages/backend/src/tokens/verify.ts | cat -n

Repository: clerk/javascript

Length of output: 1851


🏁 Script executed:

# Let me check if there's a schema validation or type guard that validates kid presence
rg -n 'kid\|validateJwt\|validateHeader' packages/backend/src/tokens/verify.ts

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Check if the JWT type is enforced at runtime or if it's just TypeScript
rg -n 'satisfies Jwt' packages/backend/src/jwt/verifyJwt.ts -A2 -B2

Repository: clerk/javascript

Length of output: 139


🏁 Script executed:

# Let's check what happens when kid is undefined in the cache and key loading
sed -n '52,100p' packages/backend/src/tokens/keys.ts

Repository: clerk/javascript

Length of output: 1428


🏁 Script executed:

# Check the complete loadClerkJWKFromRemote to see how it uses kid
sed -n '131,180p' packages/backend/src/tokens/keys.ts

Repository: clerk/javascript

Length of output: 2229


Validate kid presence in JWT header before key resolution.

The JwtHeader type declares kid: string as required, but decodeJwt does not validate its presence at runtime. If a JWT lacks a kid header, decoded.header.kid will be undefined, causing issues downstream:

  • loadClerkJwkFromPem would create a cache key like "local-undefined" instead of failing early
  • loadClerkJWKFromRemote would attempt cache lookup with undefined and throw a confusing error message referencing kid='undefined'

Add validation to ensure kid is present before calling resolveKeyAndVerifyJwt in both verifyM2MJwt (line 89) and verifyOAuthJwt (line 110).

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

In `@packages/backend/src/jwt/verifyMachineJwt.ts` at line 89, The JWT header's
`kid` must be validated before calling resolveKeyAndVerifyJwt to avoid using
undefined keys; in both verifyM2MJwt and verifyOAuthJwt check that
decoded.header.kid is a non-empty string and throw/return a clear error (e.g.,
"missing JWT kid in header") if absent, preventing downstream cache lookups like
loadClerkJwkFromPem/loadClerkJWKFromRemote from receiving `undefined`; update
the call sites that currently pass decoded.header.kid to first validate and fail
fast with a descriptive error when the kid is missing.

Comment on lines +264 to +266
if (decodedResult.payload.sub.startsWith(M2M_SUBJECT_PREFIX)) {
return verifyM2MJwt(token, decodedResult, options);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Potential runtime error: payload.sub may be undefined.

If a malformed JWT is passed with a missing or non-string sub claim, calling startsWith() on undefined will throw a TypeError. The isM2MJwt helper in machine.ts includes a type guard (typeof data.payload.sub === 'string'), but this code path accesses payload.sub directly after decode.

🐛 Proposed fix to add type guard
-    if (decodedResult.payload.sub.startsWith(M2M_SUBJECT_PREFIX)) {
+    if (typeof decodedResult.payload.sub === 'string' && decodedResult.payload.sub.startsWith(M2M_SUBJECT_PREFIX)) {
       return verifyM2MJwt(token, decodedResult, options);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/src/tokens/verify.ts` around lines 264 - 266, The code calls
decodedResult.payload.sub.startsWith(...) which can throw if sub is undefined or
not a string; update the conditional in the verify flow (the branch that
currently checks M2M_SUBJECT_PREFIX) to first ensure typeof
decodedResult.payload.sub === 'string' (or use the existing isM2MJwt type guard
from machine.ts) before calling startsWith, and only call verifyM2MJwt(token,
decodedResult, options) when that guard passes; otherwise fall through to the
non-M2M verification path.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant