Skip to content

fix(backend): harden machine JWT verification - #10826

Draft
wyattjoh wants to merge 10 commits into
mainfrom
wyattjoh/machine-jwt-exp-iss
Draft

wyattjoh wants to merge 10 commits into
mainfrom
wyattjoh/machine-jwt-exp-iss

Conversation

@wyattjoh

@wyattjoh wyattjoh commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

What

@clerk/backend gets stricter machine JWT verification. Expired and not-yet-valid tokens are rejected even when a time claim is beyond the range a JavaScript Date can represent, OAuth access token JWTs must carry the instance's issuer, and a JWKS loading failure on a machine JWT now reports why the key couldn't load instead of token-verification-failed.

Why

authenticateRequest() and verifyMachineAuthToken() accepted some tokens they should reject and misreported some failures. An expired token passed when its exp was outside the range a JavaScript Date can represent, such as -1e300. An OAuth access token JWT signed by a resolved key passed whatever its iss. When the JWKS couldn't load, callers such as @clerk/mcp-tools got token-verification-failed, as for a bad signature, and told MCP clients to discard tokens Clerk never checked.

How

  • Time claim checks compare numeric seconds instead of a Date built with setUTCSeconds. An invalid Date made the comparison NaN, so the check passed. The existing truncation and clock skew are unchanged. The same fix covers nbf and iat, and claims that aren't finite numbers are rejected as invalid (RFC 7519 §4.1.4). Values beyond the Date range are only rejected when they are expired or not yet valid: a far-future exp or a far-past nbf/iat still passes.
  • Error messages render the raw claim value (Infinity, -1e+300) when it can't be shown as a date, instead of Invalid Date or null.
  • OAuth access token JWTs must have an iss exactly equal to the Frontend API URL derived from the publishable key, even when a proxy URL is configured, because the Frontend API issues the token (AIE-2073). RFC 9068 §4: "The issuer identifier for the authorization server (which is typically obtained during discovery) MUST exactly match the value of the "iss" claim." The session-token issuer option still doesn't apply to machine tokens, and M2M JWTs and opaque tokens are unchanged.
  • verifyMachineAuthToken() fails closed: an OAuth access token JWT is rejected with token-verification-failed before any JWKS fetch when no valid publishableKey is passed, since the expected issuer can't be derived. authenticateRequest() already requires a valid publishable key, so its behaviour is unchanged.
  • A failure in the remote JWKS load keeps secret-key-invalid for an invalid secret key. A thrown fetch, a non-2xx response or an unreadable error body now reports the new MachineTokenVerificationErrorCode.RemoteJWKFailedToLoad (jwk-remote-failed-to-load). Signature, kid and claim failures, and unexpected errors outside the remote load, still report token-verification-failed.
  • The changeset is minor: the PR adds an exported error code and a new rejection for verifyMachineAuthToken() callers that don't pass a publishable key, which consumers may need to handle when upgrading.
  • resolveKeyAndVerifyJwt takes headerType and issuer as one options object, and the machine JWT test helpers are shared.

Follow-ups

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:

Part of AIE-2068

assertExpirationClaim compared a Date built with setUTCSeconds(exp). For a finite exp outside the Date range, such as -1e300, that Date is invalid, the comparison is NaN, and an expired token passed. Compare numeric seconds instead, keeping the existing truncation, skew and error messages, and apply the same fix to nbf and iat. Nonfinite time claims are rejected as invalid.

Refs AIE-2068
The machine OAuth JWT path called verifyJwt without an issuer, so a token signed by a resolved key was accepted whatever its iss. Require iss to equal the instance's Frontend API URL from the publishable key, the same authorization server the SDKs advertise in resource metadata. The session-token issuer option is still ignored for machine tokens, and M2M JWTs are unchanged.

Refs AIE-2073
…JWT test helpers

Pass resolveKeyAndVerifyJwt's headerType and issuer as one options object,
declare the machine-token publishableKey option once, share the seconds to
milliseconds conversion in the time claim assertions, and consolidate the
OAuth and session JWT test helpers and Date-limit constant.
resolveKeyAndVerifyJwt rewrapped every key-loading failure as token-verification-failed, so an invalid secret key on the JWKS call lost its secret-key-invalid reason and a JWKS outage looked like a bad token. Keep secret-key-invalid, and report a thrown JWKS fetch, a non-2xx JWKS response or an unreadable error body as the new jwk-remote-failed-to-load machine error code. Signature, claim and kid failures keep token-verification-failed.
Only a failure of loadClerkJWKFromRemote now maps to the remote key-loading
reasons. An unexpected error from the local jwtKey path or verifyJwt keeps
token-verification-failed, as before the key-loading change.
@vercel

vercel Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
clerk-js-sandbox Ready Ready Preview Nov 14, 2026 9:46am UTC
swingset Ready Ready Preview Nov 14, 2026 9:46am UTC

Request Review

@changeset-bot

changeset-bot Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 78af740

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

This PR includes changesets to release 11 packages
Name Type
@clerk/backend Minor
@clerk/astro Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/hono Patch
@clerk/nextjs Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch
@clerk/swingset 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

@coderabbitai

coderabbitai Bot commented Nov 14, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@pkg-pr-new

pkg-pr-new Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@10826

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@10826

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@10826

@clerk/expo

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

@clerk/expo-biometrics

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

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@10826

@clerk/expo-native-components

npm i https://pkg.pr.new/@clerk/expo-native-components@10826

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/mosaic

npm i https://pkg.pr.new/@clerk/mosaic@10826

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 78af740

wyattjoh added a commit to clerk/mcp-tools that referenced this pull request Nov 14, 2026
…ent Hono's cors() interaction

No @clerk/backend release reports jwk-remote-failed-to-load yet, so the
comments cite the PR that adds it, and the prefix rule is dropped once
the supported backend floor reports the reason.

Behind Hono's cors() middleware, the metadata GET keeps the headers
cors() sets: cors() writes them to c.res, and Hono's res setter copies
them over the handler's response. A test pins this; the README and
changeset say so, unlike Express, where the handlers' headers win.
wyattjoh added a commit to clerk/mcp-tools that referenced this pull request Nov 14, 2026
…r the JWKS reason

No @clerk/backend release reports jwk-remote-failed-to-load yet (3.29.0
still maps JWKS failures to token-verification-failed), so the READMEs
and changeset describe current releases and say what changes once
clerk/javascript#10826 ships.

This branch was successfully deployed

2 active deployments
Preview – swingset — 78af740b Deployed Nov 14, 2026 by vercel[bot]
Preview – clerk-js-sandbox — 78af740b Deployed Nov 14, 2026 by vercel[bot]
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.

1 participant