Skip to content

fix(backend): harden machine-token verification and redact debug-logged tokens#8744

Merged
jacekradko merged 6 commits into
mainfrom
jacek/backend-token-hardening
Jun 4, 2026
Merged

fix(backend): harden machine-token verification and redact debug-logged tokens#8744
jacekradko merged 6 commits into
mainfrom
jacek/backend-token-hardening

Conversation

@jacekradko
Copy link
Copy Markdown
Member

@jacekradko jacekradko commented Jun 3, 2026

Two backend hardening fixes that came out of a security scan of the token-verification paths.

verifyMachineAuthToken could throw on a crafted token: an at+jwt JWT with a missing or non-string sub reached decodedResult.payload.sub.startsWith(...) before verification ran, and the TypeError escaped the { errors } return contract the request.ts callers depend on. Because the framework middlewares authenticate with acceptsToken: 'any', an unauthenticated request could trigger it through the Authorization header. The fix is the same typeof sub === 'string' guard isM2MJwt already uses, so the token falls through to OAuth verification and comes back as a typed error.

Separately, the auth debug payload was logging live tokens. createDebug already truncates secretKey/jwtKey, but signedInAuthObject spreads the whole authenticateContext into the payload, so every bearer field riding along on it leaked at full length once middleware debug logging was on. That's the session token plus tokenInHeader, sessionTokenInCookie, refreshTokenInCookie, devBrowserToken and handshakeToken. The refresh token is the one that stings: it's long-lived. All of them are now truncated at the source, which covers every consumer rather than just the Next.js formatter where this first surfaced.

Both paths gained regression tests that I confirmed fail without the fix.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jun 3, 2026

🦋 Changeset detected

Latest commit: acf2352

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

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

@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 3, 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 Jun 4, 2026 2:12am

Request Review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 3, 2026

Break Check: no API changes detected across the tracked packages.

Last ran on acf2352. Pushes that change no tracked declarations (no API surface change vs. base) are skipped and don't update this comment.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro

Run ID: 8ae46baf-6cf5-4d7f-b35f-df9622d18435

📥 Commits

Reviewing files that changed from the base of the PR and between 201020d and acf2352.

📒 Files selected for processing (3)
  • .changeset/redact-tokens-debug-output.md
  • packages/backend/src/tokens/__tests__/authObjects.test.ts
  • packages/backend/src/tokens/authObjects.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • .changeset/redact-tokens-debug-output.md
  • packages/backend/src/tokens/authObjects.ts
  • packages/backend/src/tokens/tests/authObjects.test.ts

📝 Walkthrough

Walkthrough

This PR adds a type guard in verifyMachineAuthToken to ensure decodedResult.payload.sub is a string before calling startsWith, preventing runtime errors and routing non-string/missing subs to OAuth verification. It extends auth debug redaction in the backend to truncate sessionToken, tokenInHeader, sessionTokenInCookie, refreshTokenInCookie, devBrowserToken, and handshakeToken, and updates the nextjs logFormatter to recursively truncate credential-like keys at any nesting depth. Both areas include unit/regression tests and Changeset entries.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: hardening machine-token verification and redacting debug-logged tokens, matching the core objectives of the PR.
Description check ✅ Passed The description clearly explains both hardening fixes, their security implications, and addresses all major code changes in the PR with appropriate technical detail.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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
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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/backend/src/tokens/authObjects.ts`:
- Around line 173-178: The debug output still includes long-lived bearer fields
(refreshTokenInCookie, devBrowserToken, handshakeToken) via AuthenticateContext
when signedInAuthObject builds debugData, so update the redaction logic to
truncate those fields the same way as sessionToken: locate the lines setting
res.sessionToken/res.tokenInHeader/res.sessionTokenInCookie and also redact
res.refreshTokenInCookie, res.devBrowserToken, and res.handshakeToken (e.g., set
each to (value||'').substring(0,7) or remove them from the debug object) and
ensure signedInAuthObject uses the redacted AuthenticateContext when creating
debugData so no full tokens are ever spread into logs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro

Run ID: edfaa399-5a58-4e32-a53e-148ec84b39d1

📥 Commits

Reviewing files that changed from the base of the PR and between 8e1bd48 and 4637795.

📒 Files selected for processing (6)
  • .changeset/machine-token-sub-guard.md
  • .changeset/redact-tokens-debug-output.md
  • packages/backend/src/tokens/__tests__/authObjects.test.ts
  • packages/backend/src/tokens/__tests__/verify.test.ts
  • packages/backend/src/tokens/authObjects.ts
  • packages/backend/src/tokens/verify.ts

Comment thread packages/backend/src/tokens/authObjects.ts
A malformed OAuth (at+jwt) bearer token with a missing or non-string sub
caused verifyMachineAuthToken to throw a TypeError on
decodedResult.payload.sub.startsWith() before token verification ran,
surfacing as an unhandled error during request authentication. Mirror the
typeof guard already used by isM2MJwt so such tokens are classified and
rejected with a typed verification error instead.
The auth object's debug payload spread the full sessionToken, tokenInHeader
and sessionTokenInCookie into its output, so SDKs that enable middleware
debug logging wrote live bearer credentials to logs. Truncate them to a
short, non-reconstructable prefix, matching the existing secretKey/jwtKey
handling.
@jacekradko jacekradko force-pushed the jacek/backend-token-hardening branch from 4637795 to f9d1a12 Compare June 3, 2026 22:19
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 3, 2026

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: acf2352

…tput

The debug log formatter only masked top-level string values, so a bearer
token nested in a debug object (for example under debug.sessionToken) was
stringified in full. Recursively truncate known credential keys at any depth
as a defense-in-depth backstop alongside the source-level redaction in
@clerk/backend.
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.

🧹 Nitpick comments (3)
packages/nextjs/src/utils/logFormatter.ts (1)

42-52: ⚡ Quick win

Add an explicit return type to the exported formatter.

Please annotate logFormatter as : string so this public utility matches the repo's TypeScript API rule.

As per coding guidelines, **/*.{ts,tsx}: Always define explicit return types for functions, especially public APIs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nextjs/src/utils/logFormatter.ts` around lines 42 - 52, Add an
explicit return type annotation of string to the exported formatter function so
the public API follows the repo rule; update the signature of logFormatter
(which accepts LogEntry and uses maskSecretKey and redactSensitive internally)
to declare it returns string (e.g., annotate the const/arrow function with :
string) and keep the existing implementation unchanged.
packages/nextjs/src/utils/__tests__/logFormatter.test.ts (2)

6-29: ⚡ Quick win

Cover secretKey and jwtKey in the redaction matrix.

logFormatter now special-cases five sensitive keys, but this regression only asserts the three token fields. Adding secretKey and jwtKey here would better lock the security contract to the same key set used in packages/backend/src/tokens/authObjects.ts:168-181.

Suggested assertion expansion
         debug: {
+          secretKey: 'sk_test_51H8P1h7v123456789',
+          jwtKey: 'eyJhbGciOiJSUzI1NiJ9.jwt-key-should-not-appear',
           sessionToken: 'eyJhbGciOiJSUzI1NiJ9.payload.full-session-segment-should-not-appear',
           tokenInHeader: 'eyJhbGciOiJSUzI1NiJ9.payload.header-segment-should-not-appear',
           sessionTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.payload.cookie-segment-should-not-appear',
         },
@@
+    expect(output).not.toContain('jwt-key-should-not-appear');
+    expect(output).toContain('"secretKey": "sk_test"');
+    expect(output).toContain('"jwtKey": "eyJhbGc"');
     expect(output).toContain('"sessionToken": "eyJhbGc"');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nextjs/src/utils/__tests__/logFormatter.test.ts` around lines 6 -
29, The test 'truncates sensitive token keys nested in debug objects' currently
asserts redaction for three token fields; extend it to also assert that any
values under the additional sensitive keys secretKey and jwtKey are redacted by
logFormatter and that only the short non-reconstructable prefix remains (e.g.,
expect output not toContain the full secret/jwt payload and toContain the short
prefix form). Update the test entry object's debug payload to include secretKey
and jwtKey sample long tokens and add corresponding expect(...) checks mirroring
the existing sessionToken/tokenInHeader/sessionTokenInCookie assertions so the
test covers the same key set used by the redaction logic.

1-3: ⚡ Quick win

Avoid as any in these tests.

These casts bypass the LogEntry contract the test is supposed to exercise. Prefer a typed fixture (const entry: LogEntry = ... or satisfies LogEntry) and a type-only import from ../debugLogger.

Suggested cleanup
+import type { LogEntry } from '../debugLogger';
 import { describe, expect, it } from 'vitest';
 
 import { logFormatter } from '../logFormatter';
@@
-    const entry = [
+    const entry: LogEntry = [
@@
-    const output = logFormatter(entry as any);
+    const output = logFormatter(entry);
@@
-    const entry = ['auth', { debug: { sessionToken: 'eyJhbGc' } }];
+    const entry: LogEntry = ['auth', { debug: { sessionToken: 'eyJhbGc' } }];
@@
-    const output = logFormatter(entry as any);
+    const output = logFormatter(entry);
As per coding guidelines, `**/*.{ts,tsx}`: Avoid `any` type - prefer `unknown` when type is uncertain, then narrow with type guards, and `Verify type-only imports where possible for better tree-shaking in code review`.

Also applies to: 7-19, 32-34

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nextjs/src/utils/__tests__/logFormatter.test.ts` around lines 1 - 3,
Tests use `as any` to cast fixtures which bypasses the LogEntry contract;
replace those casts with proper typed fixtures and type-only imports. Define
test entries with the LogEntry type (or `satisfies LogEntry`) instead of `as
any`, import the LogEntry type from `../debugLogger` as a type-only import, and
when a value is genuinely unknown use `unknown` then narrow with guards before
passing to logFormatter; update occurrences around the test file where fixtures
are cast (references: logFormatter and LogEntry) to remove `as any` and ensure
test fixtures conform to the LogEntry shape.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/nextjs/src/utils/__tests__/logFormatter.test.ts`:
- Around line 6-29: The test 'truncates sensitive token keys nested in debug
objects' currently asserts redaction for three token fields; extend it to also
assert that any values under the additional sensitive keys secretKey and jwtKey
are redacted by logFormatter and that only the short non-reconstructable prefix
remains (e.g., expect output not toContain the full secret/jwt payload and
toContain the short prefix form). Update the test entry object's debug payload
to include secretKey and jwtKey sample long tokens and add corresponding
expect(...) checks mirroring the existing
sessionToken/tokenInHeader/sessionTokenInCookie assertions so the test covers
the same key set used by the redaction logic.
- Around line 1-3: Tests use `as any` to cast fixtures which bypasses the
LogEntry contract; replace those casts with proper typed fixtures and type-only
imports. Define test entries with the LogEntry type (or `satisfies LogEntry`)
instead of `as any`, import the LogEntry type from `../debugLogger` as a
type-only import, and when a value is genuinely unknown use `unknown` then
narrow with guards before passing to logFormatter; update occurrences around the
test file where fixtures are cast (references: logFormatter and LogEntry) to
remove `as any` and ensure test fixtures conform to the LogEntry shape.

In `@packages/nextjs/src/utils/logFormatter.ts`:
- Around line 42-52: Add an explicit return type annotation of string to the
exported formatter function so the public API follows the repo rule; update the
signature of logFormatter (which accepts LogEntry and uses maskSecretKey and
redactSensitive internally) to declare it returns string (e.g., annotate the
const/arrow function with : string) and keep the existing implementation
unchanged.

ℹ️ Review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro

Run ID: c5a8281e-87e6-4378-847f-c0d4df589458

📥 Commits

Reviewing files that changed from the base of the PR and between 4cc1989 and 201020d.

📒 Files selected for processing (3)
  • .changeset/redact-nested-tokens-debug-formatter.md
  • packages/nextjs/src/utils/__tests__/logFormatter.test.ts
  • packages/nextjs/src/utils/logFormatter.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/redact-nested-tokens-debug-formatter.md

Copy link
Copy Markdown
Member

@wobsoriano wobsoriano left a comment

Choose a reason for hiding this comment

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

nice find, thanks!

@jacekradko jacekradko merged commit 27c4d75 into main Jun 4, 2026
45 checks passed
@jacekradko jacekradko deleted the jacek/backend-token-hardening branch June 4, 2026 03:25
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.

2 participants