Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/machine-token-sub-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Prevent an unhandled exception when verifying a machine token whose JWT payload has a missing or non-string `sub`. Such tokens are now classified and rejected with a typed verification error instead of throwing, so a crafted `Authorization` header can no longer surface as an unhandled error during request authentication.
5 changes: 5 additions & 0 deletions .changeset/redact-nested-tokens-debug-formatter.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Harden middleware debug log output: the formatter now recursively truncates known credential keys (`sessionToken`, `tokenInHeader`, `sessionTokenInCookie`, `secretKey`, `jwtKey`) at any nesting depth, so a bearer token can no longer reach the logs even if a debug producer nests one. This is a defense-in-depth backstop alongside the source-level redaction in `@clerk/backend`.
5 changes: 5 additions & 0 deletions .changeset/redact-tokens-debug-output.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Redact raw bearer credentials from the `auth` object's debug output. The debug payload (surfaced when an SDK enables middleware debug logging) previously included full session, machine, refresh, dev-browser and handshake tokens; each now exposes only a short, non-reconstructable prefix, matching how `secretKey` and `jwtKey` are already handled.
30 changes: 30 additions & 0 deletions packages/backend/src/tokens/__tests__/authObjects.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('signedInAuthObject', () => {
expect(token).toBe('token');
});

it('redacts raw session and machine tokens from debug output', () => {
const rawSessionToken = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.this-segment-must-never-be-logged';
const mockAuthenticateContext = {
tokenInHeader: 'eyJhbGciOiJSUzI1NiJ9.header-bearer.this-segment-must-never-be-logged',
sessionTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.cookie-bearer.this-segment-must-never-be-logged',
refreshTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.refresh-bearer.this-segment-must-never-be-logged',
devBrowserToken: 'eyJhbGciOiJSUzI1NiJ9.devbrowser-bearer.this-segment-must-never-be-logged',
handshakeToken: 'eyJhbGciOiJSUzI1NiJ9.handshake-bearer.this-segment-must-never-be-logged',
} as unknown as AuthenticateContext;

const authObject = signedInAuthObject(mockAuthenticateContext, rawSessionToken, {
sub: 'userId',
} as unknown as JwtPayload);

const debug = authObject.debug() as Record<string, string>;

// Only a short, non-reconstructable prefix of each bearer credential is exposed.
expect(debug.sessionToken).toBe('eyJhbGc');
expect(debug.tokenInHeader).toBe('eyJhbGc');
expect(debug.sessionTokenInCookie).toBe('eyJhbGc');
expect(debug.refreshTokenInCookie).toBe('eyJhbGc');
expect(debug.devBrowserToken).toBe('eyJhbGc');
expect(debug.handshakeToken).toBe('eyJhbGc');

// The full tokens must not be recoverable from the serialized debug payload.
const serialized = JSON.stringify(debug);
expect(serialized).not.toContain('this-segment-must-never-be-logged');
expect(serialized).not.toContain(rawSessionToken);
});

describe('JWT v1', () => {
it('has() for user scope', () => {
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
Expand Down
39 changes: 39 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -509,6 +509,45 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
expect(result.errors).toBeDefined();
expect(result.errors?.[0].message).toContain('expired');
});

// Regression: `decodedResult.payload.sub.startsWith(...)` previously threw a
// TypeError for a missing or non-string `sub` before OAuth verification ran, so a
// crafted at+jwt bearer token surfaced as an unhandled error in request auth.
it.each([
['a missing', undefined],
['a null', null],
['a numeric', 123],
['an object', {}],
] as Array<[string, unknown]>)(
'classifies an at+jwt token with %s sub as OAuth instead of throwing',
async (_label, sub) => {
server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => {
return HttpResponse.json(mockJwks);
}),
),
);

const payload: Record<string, unknown> = { ...mockOAuthAccessTokenJwtPayload };
if (sub === undefined) {
delete payload.sub;
} else {
payload.sub = sub;
}

const oauthJwt = await createSignedOAuthJwt(payload as typeof mockOAuthAccessTokenJwtPayload, 'at+jwt');

const result = await verifyMachineAuthToken(oauthJwt, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
});

// Reaching a typed OAuth result proves the M2M `sub` check no longer throws.
expect(result.tokenType).toBe('oauth_token');
},
);
});

describe('verifyM2MToken with JWT', () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/tokens/authObjects.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,19 @@ const createDebug = (data: AuthObjectDebugData | undefined) => {
const res = { ...data };
res.secretKey = (res.secretKey || '').substring(0, 7);
res.jwtKey = (res.jwtKey || '').substring(0, 7);
// Session and machine tokens are live bearer credentials, so only ever expose a
// short, non-reconstructable prefix here, the same way secretKey/jwtKey are handled
// above. Otherwise enabling debug logging would write usable tokens to logs.
// This also covers the bearer fields carried on AuthenticateContext, which is spread
// wholesale into the debug payload by signedInAuthObject: the refresh token is the
// most sensitive of these, and the dev-browser/handshake tokens are short-lived but
// still credentials.
res.sessionToken = (res.sessionToken || '').substring(0, 7);
res.tokenInHeader = (res.tokenInHeader || '').substring(0, 7);
res.sessionTokenInCookie = (res.sessionTokenInCookie || '').substring(0, 7);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.refreshTokenInCookie = (res.refreshTokenInCookie || '').substring(0, 7);
res.devBrowserToken = (res.devBrowserToken || '').substring(0, 7);
res.handshakeToken = (res.handshakeToken || '').substring(0, 7);
return { ...res };
};
};
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,7 +261,7 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken
} as MachineTokenReturnType<never, MachineTokenVerificationError>;
}

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);
}

Expand Down
38 changes: 38 additions & 0 deletions packages/nextjs/src/utils/__tests__/logFormatter.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';

import { logFormatter } from '../logFormatter';

describe('logFormatter', () => {
it('truncates sensitive token keys nested in debug objects', () => {
const entry = [
'auth',
{
auth: { userId: 'user_123' },
debug: {
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',
},
},
];

const output = logFormatter(entry as any);

// Full bearer tokens nested under known keys must not survive formatting.
expect(output).not.toContain('full-session-segment-should-not-appear');
expect(output).not.toContain('header-segment-should-not-appear');
expect(output).not.toContain('cookie-segment-should-not-appear');
// Only the short, non-reconstructable prefix remains.
expect(output).toContain('"sessionToken": "eyJhbGc"');
// Non-sensitive nested data is preserved.
expect(output).toContain('"userId": "user_123"');
});

it('is idempotent for values already truncated at the source', () => {
const entry = ['auth', { debug: { sessionToken: 'eyJhbGc' } }];

const output = logFormatter(entry as any);

expect(output).toContain('"sessionToken": "eyJhbGc"');
});
});
29 changes: 27 additions & 2 deletions packages/nextjs/src/utils/logFormatter.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import type { LogEntry } from './debugLogger';

// Keys whose values are live bearer credentials or secrets. Their values are
// truncated at any nesting depth, as a defense-in-depth backstop for debug
// producers that nest sensitive data. The authoritative redaction still happens
// at the source (e.g. @clerk/backend's auth-object debug output already truncates
// these); truncating to the same 7-char prefix here keeps that output stable.
const SENSITIVE_KEYS = new Set(['sessionToken', 'tokenInHeader', 'sessionTokenInCookie', 'secretKey', 'jwtKey']);

// Move to shared once clerk/shared is used in clerk/nextjs
const maskSecretKey = (str: any) => {
if (!str || typeof str !== 'string') {
Expand All@@ -13,15 +20,33 @@ const maskSecretKey = (str: any) => {
}
};

// Recursively redacts sensitive values. A string under a known sensitive key is
// truncated regardless of depth; every other string is still run through
// maskSecretKey so `sk_*` keys are masked wherever they appear.
const redactSensitive = (value: unknown, key?: string): unknown => {
if (key && SENSITIVE_KEYS.has(key) && typeof value === 'string') {
return value.substring(0, 7);
}

if (Array.isArray(value)) {
return value.map(item => redactSensitive(item));
}

if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactSensitive(v, k)]));
}

return maskSecretKey(value);
};

export const logFormatter = (entry: LogEntry) => {
return (Array.isArray(entry) ? entry : [entry])
.map(entry => {
if (typeof entry === 'string') {
return maskSecretKey(entry);
}

const masked = Object.fromEntries(Object.entries(entry).map(([k, v]) => [k, maskSecretKey(v)]));
return JSON.stringify(masked, null, 2);
return JSON.stringify(redactSensitive(entry), null, 2);
})
.join(', ');
};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(backend): harden machine-token verification and redact debug-logged tokens by jacekradko · Pull Request #8744 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/machine-token-sub-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Prevent an unhandled exception when verifying a machine token whose JWT payload has a missing or non-string `sub`. Such tokens are now classified and rejected with a typed verification error instead of throwing, so a crafted `Authorization` header can no longer surface as an unhandled error during request authentication.
5 changes: 5 additions & 0 deletions .changeset/redact-nested-tokens-debug-formatter.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Harden middleware debug log output: the formatter now recursively truncates known credential keys (`sessionToken`, `tokenInHeader`, `sessionTokenInCookie`, `secretKey`, `jwtKey`) at any nesting depth, so a bearer token can no longer reach the logs even if a debug producer nests one. This is a defense-in-depth backstop alongside the source-level redaction in `@clerk/backend`.
5 changes: 5 additions & 0 deletions .changeset/redact-tokens-debug-output.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Redact raw bearer credentials from the `auth` object's debug output. The debug payload (surfaced when an SDK enables middleware debug logging) previously included full session, machine, refresh, dev-browser and handshake tokens; each now exposes only a short, non-reconstructable prefix, matching how `secretKey` and `jwtKey` are already handled.
30 changes: 30 additions & 0 deletions packages/backend/src/tokens/__tests__/authObjects.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('signedInAuthObject', () => {
expect(token).toBe('token');
});

it('redacts raw session and machine tokens from debug output', () => {
const rawSessionToken = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.this-segment-must-never-be-logged';
const mockAuthenticateContext = {
tokenInHeader: 'eyJhbGciOiJSUzI1NiJ9.header-bearer.this-segment-must-never-be-logged',
sessionTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.cookie-bearer.this-segment-must-never-be-logged',
refreshTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.refresh-bearer.this-segment-must-never-be-logged',
devBrowserToken: 'eyJhbGciOiJSUzI1NiJ9.devbrowser-bearer.this-segment-must-never-be-logged',
handshakeToken: 'eyJhbGciOiJSUzI1NiJ9.handshake-bearer.this-segment-must-never-be-logged',
} as unknown as AuthenticateContext;

const authObject = signedInAuthObject(mockAuthenticateContext, rawSessionToken, {
sub: 'userId',
} as unknown as JwtPayload);

const debug = authObject.debug() as Record<string, string>;

// Only a short, non-reconstructable prefix of each bearer credential is exposed.
expect(debug.sessionToken).toBe('eyJhbGc');
expect(debug.tokenInHeader).toBe('eyJhbGc');
expect(debug.sessionTokenInCookie).toBe('eyJhbGc');
expect(debug.refreshTokenInCookie).toBe('eyJhbGc');
expect(debug.devBrowserToken).toBe('eyJhbGc');
expect(debug.handshakeToken).toBe('eyJhbGc');

// The full tokens must not be recoverable from the serialized debug payload.
const serialized = JSON.stringify(debug);
expect(serialized).not.toContain('this-segment-must-never-be-logged');
expect(serialized).not.toContain(rawSessionToken);
});

describe('JWT v1', () => {
it('has() for user scope', () => {
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
Expand Down
39 changes: 39 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -509,6 +509,45 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
expect(result.errors).toBeDefined();
expect(result.errors?.[0].message).toContain('expired');
});

// Regression: `decodedResult.payload.sub.startsWith(...)` previously threw a
// TypeError for a missing or non-string `sub` before OAuth verification ran, so a
// crafted at+jwt bearer token surfaced as an unhandled error in request auth.
it.each([
['a missing', undefined],
['a null', null],
['a numeric', 123],
['an object', {}],
] as Array<[string, unknown]>)(
'classifies an at+jwt token with %s sub as OAuth instead of throwing',
async (_label, sub) => {
server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => {
return HttpResponse.json(mockJwks);
}),
),
);

const payload: Record<string, unknown> = { ...mockOAuthAccessTokenJwtPayload };
if (sub === undefined) {
delete payload.sub;
} else {
payload.sub = sub;
}

const oauthJwt = await createSignedOAuthJwt(payload as typeof mockOAuthAccessTokenJwtPayload, 'at+jwt');

const result = await verifyMachineAuthToken(oauthJwt, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
});

// Reaching a typed OAuth result proves the M2M `sub` check no longer throws.
expect(result.tokenType).toBe('oauth_token');
},
);
});

describe('verifyM2MToken with JWT', () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/tokens/authObjects.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,19 @@ const createDebug = (data: AuthObjectDebugData | undefined) => {
const res = { ...data };
res.secretKey = (res.secretKey || '').substring(0, 7);
res.jwtKey = (res.jwtKey || '').substring(0, 7);
// Session and machine tokens are live bearer credentials, so only ever expose a
// short, non-reconstructable prefix here, the same way secretKey/jwtKey are handled
// above. Otherwise enabling debug logging would write usable tokens to logs.
// This also covers the bearer fields carried on AuthenticateContext, which is spread
// wholesale into the debug payload by signedInAuthObject: the refresh token is the
// most sensitive of these, and the dev-browser/handshake tokens are short-lived but
// still credentials.
res.sessionToken = (res.sessionToken || '').substring(0, 7);
res.tokenInHeader = (res.tokenInHeader || '').substring(0, 7);
res.sessionTokenInCookie = (res.sessionTokenInCookie || '').substring(0, 7);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.refreshTokenInCookie = (res.refreshTokenInCookie || '').substring(0, 7);
res.devBrowserToken = (res.devBrowserToken || '').substring(0, 7);
res.handshakeToken = (res.handshakeToken || '').substring(0, 7);
return { ...res };
};
};
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,7 +261,7 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken
} as MachineTokenReturnType<never, MachineTokenVerificationError>;
}

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);
}

Expand Down
38 changes: 38 additions & 0 deletions packages/nextjs/src/utils/__tests__/logFormatter.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';

import { logFormatter } from '../logFormatter';

describe('logFormatter', () => {
it('truncates sensitive token keys nested in debug objects', () => {
const entry = [
'auth',
{
auth: { userId: 'user_123' },
debug: {
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',
},
},
];

const output = logFormatter(entry as any);

// Full bearer tokens nested under known keys must not survive formatting.
expect(output).not.toContain('full-session-segment-should-not-appear');
expect(output).not.toContain('header-segment-should-not-appear');
expect(output).not.toContain('cookie-segment-should-not-appear');
// Only the short, non-reconstructable prefix remains.
expect(output).toContain('"sessionToken": "eyJhbGc"');
// Non-sensitive nested data is preserved.
expect(output).toContain('"userId": "user_123"');
});

it('is idempotent for values already truncated at the source', () => {
const entry = ['auth', { debug: { sessionToken: 'eyJhbGc' } }];

const output = logFormatter(entry as any);

expect(output).toContain('"sessionToken": "eyJhbGc"');
});
});
29 changes: 27 additions & 2 deletions packages/nextjs/src/utils/logFormatter.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import type { LogEntry } from './debugLogger';

// Keys whose values are live bearer credentials or secrets. Their values are
// truncated at any nesting depth, as a defense-in-depth backstop for debug
// producers that nest sensitive data. The authoritative redaction still happens
// at the source (e.g. @clerk/backend's auth-object debug output already truncates
// these); truncating to the same 7-char prefix here keeps that output stable.
const SENSITIVE_KEYS = new Set(['sessionToken', 'tokenInHeader', 'sessionTokenInCookie', 'secretKey', 'jwtKey']);

// Move to shared once clerk/shared is used in clerk/nextjs
const maskSecretKey = (str: any) => {
if (!str || typeof str !== 'string') {
Expand All@@ -13,15 +20,33 @@ const maskSecretKey = (str: any) => {
}
};

// Recursively redacts sensitive values. A string under a known sensitive key is
// truncated regardless of depth; every other string is still run through
// maskSecretKey so `sk_*` keys are masked wherever they appear.
const redactSensitive = (value: unknown, key?: string): unknown => {
if (key && SENSITIVE_KEYS.has(key) && typeof value === 'string') {
return value.substring(0, 7);
}

if (Array.isArray(value)) {
return value.map(item => redactSensitive(item));
}

if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactSensitive(v, k)]));
}

return maskSecretKey(value);
};

export const logFormatter = (entry: LogEntry) => {
return (Array.isArray(entry) ? entry : [entry])
.map(entry => {
if (typeof entry === 'string') {
return maskSecretKey(entry);
}

const masked = Object.fromEntries(Object.entries(entry).map(([k, v]) => [k, maskSecretKey(v)]));
return JSON.stringify(masked, null, 2);
return JSON.stringify(redactSensitive(entry), null, 2);
})
.join(', ');
};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): harden machine-token verification and redact debug-logged tokens by jacekradko · Pull Request #8744 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/machine-token-sub-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Prevent an unhandled exception when verifying a machine token whose JWT payload has a missing or non-string `sub`. Such tokens are now classified and rejected with a typed verification error instead of throwing, so a crafted `Authorization` header can no longer surface as an unhandled error during request authentication.
5 changes: 5 additions & 0 deletions .changeset/redact-nested-tokens-debug-formatter.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Harden middleware debug log output: the formatter now recursively truncates known credential keys (`sessionToken`, `tokenInHeader`, `sessionTokenInCookie`, `secretKey`, `jwtKey`) at any nesting depth, so a bearer token can no longer reach the logs even if a debug producer nests one. This is a defense-in-depth backstop alongside the source-level redaction in `@clerk/backend`.
5 changes: 5 additions & 0 deletions .changeset/redact-tokens-debug-output.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Redact raw bearer credentials from the `auth` object's debug output. The debug payload (surfaced when an SDK enables middleware debug logging) previously included full session, machine, refresh, dev-browser and handshake tokens; each now exposes only a short, non-reconstructable prefix, matching how `secretKey` and `jwtKey` are already handled.
30 changes: 30 additions & 0 deletions packages/backend/src/tokens/__tests__/authObjects.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('signedInAuthObject', () => {
expect(token).toBe('token');
});

it('redacts raw session and machine tokens from debug output', () => {
const rawSessionToken = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.this-segment-must-never-be-logged';
const mockAuthenticateContext = {
tokenInHeader: 'eyJhbGciOiJSUzI1NiJ9.header-bearer.this-segment-must-never-be-logged',
sessionTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.cookie-bearer.this-segment-must-never-be-logged',
refreshTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.refresh-bearer.this-segment-must-never-be-logged',
devBrowserToken: 'eyJhbGciOiJSUzI1NiJ9.devbrowser-bearer.this-segment-must-never-be-logged',
handshakeToken: 'eyJhbGciOiJSUzI1NiJ9.handshake-bearer.this-segment-must-never-be-logged',
} as unknown as AuthenticateContext;

const authObject = signedInAuthObject(mockAuthenticateContext, rawSessionToken, {
sub: 'userId',
} as unknown as JwtPayload);

const debug = authObject.debug() as Record<string, string>;

// Only a short, non-reconstructable prefix of each bearer credential is exposed.
expect(debug.sessionToken).toBe('eyJhbGc');
expect(debug.tokenInHeader).toBe('eyJhbGc');
expect(debug.sessionTokenInCookie).toBe('eyJhbGc');
expect(debug.refreshTokenInCookie).toBe('eyJhbGc');
expect(debug.devBrowserToken).toBe('eyJhbGc');
expect(debug.handshakeToken).toBe('eyJhbGc');

// The full tokens must not be recoverable from the serialized debug payload.
const serialized = JSON.stringify(debug);
expect(serialized).not.toContain('this-segment-must-never-be-logged');
expect(serialized).not.toContain(rawSessionToken);
});

describe('JWT v1', () => {
it('has() for user scope', () => {
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
Expand Down
39 changes: 39 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -509,6 +509,45 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
expect(result.errors).toBeDefined();
expect(result.errors?.[0].message).toContain('expired');
});

// Regression: `decodedResult.payload.sub.startsWith(...)` previously threw a
// TypeError for a missing or non-string `sub` before OAuth verification ran, so a
// crafted at+jwt bearer token surfaced as an unhandled error in request auth.
it.each([
['a missing', undefined],
['a null', null],
['a numeric', 123],
['an object', {}],
] as Array<[string, unknown]>)(
'classifies an at+jwt token with %s sub as OAuth instead of throwing',
async (_label, sub) => {
server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => {
return HttpResponse.json(mockJwks);
}),
),
);

const payload: Record<string, unknown> = { ...mockOAuthAccessTokenJwtPayload };
if (sub === undefined) {
delete payload.sub;
} else {
payload.sub = sub;
}

const oauthJwt = await createSignedOAuthJwt(payload as typeof mockOAuthAccessTokenJwtPayload, 'at+jwt');

const result = await verifyMachineAuthToken(oauthJwt, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
});

// Reaching a typed OAuth result proves the M2M `sub` check no longer throws.
expect(result.tokenType).toBe('oauth_token');
},
);
});

describe('verifyM2MToken with JWT', () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/tokens/authObjects.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,19 @@ const createDebug = (data: AuthObjectDebugData | undefined) => {
const res = { ...data };
res.secretKey = (res.secretKey || '').substring(0, 7);
res.jwtKey = (res.jwtKey || '').substring(0, 7);
// Session and machine tokens are live bearer credentials, so only ever expose a
// short, non-reconstructable prefix here, the same way secretKey/jwtKey are handled
// above. Otherwise enabling debug logging would write usable tokens to logs.
// This also covers the bearer fields carried on AuthenticateContext, which is spread
// wholesale into the debug payload by signedInAuthObject: the refresh token is the
// most sensitive of these, and the dev-browser/handshake tokens are short-lived but
// still credentials.
res.sessionToken = (res.sessionToken || '').substring(0, 7);
res.tokenInHeader = (res.tokenInHeader || '').substring(0, 7);
res.sessionTokenInCookie = (res.sessionTokenInCookie || '').substring(0, 7);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.refreshTokenInCookie = (res.refreshTokenInCookie || '').substring(0, 7);
res.devBrowserToken = (res.devBrowserToken || '').substring(0, 7);
res.handshakeToken = (res.handshakeToken || '').substring(0, 7);
return { ...res };
};
};
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,7 +261,7 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken
} as MachineTokenReturnType<never, MachineTokenVerificationError>;
}

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);
}

Expand Down
38 changes: 38 additions & 0 deletions packages/nextjs/src/utils/__tests__/logFormatter.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';

import { logFormatter } from '../logFormatter';

describe('logFormatter', () => {
it('truncates sensitive token keys nested in debug objects', () => {
const entry = [
'auth',
{
auth: { userId: 'user_123' },
debug: {
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',
},
},
];

const output = logFormatter(entry as any);

// Full bearer tokens nested under known keys must not survive formatting.
expect(output).not.toContain('full-session-segment-should-not-appear');
expect(output).not.toContain('header-segment-should-not-appear');
expect(output).not.toContain('cookie-segment-should-not-appear');
// Only the short, non-reconstructable prefix remains.
expect(output).toContain('"sessionToken": "eyJhbGc"');
// Non-sensitive nested data is preserved.
expect(output).toContain('"userId": "user_123"');
});

it('is idempotent for values already truncated at the source', () => {
const entry = ['auth', { debug: { sessionToken: 'eyJhbGc' } }];

const output = logFormatter(entry as any);

expect(output).toContain('"sessionToken": "eyJhbGc"');
});
});
29 changes: 27 additions & 2 deletions packages/nextjs/src/utils/logFormatter.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import type { LogEntry } from './debugLogger';

// Keys whose values are live bearer credentials or secrets. Their values are
// truncated at any nesting depth, as a defense-in-depth backstop for debug
// producers that nest sensitive data. The authoritative redaction still happens
// at the source (e.g. @clerk/backend's auth-object debug output already truncates
// these); truncating to the same 7-char prefix here keeps that output stable.
const SENSITIVE_KEYS = new Set(['sessionToken', 'tokenInHeader', 'sessionTokenInCookie', 'secretKey', 'jwtKey']);

// Move to shared once clerk/shared is used in clerk/nextjs
const maskSecretKey = (str: any) => {
if (!str || typeof str !== 'string') {
Expand All@@ -13,15 +20,33 @@ const maskSecretKey = (str: any) => {
}
};

// Recursively redacts sensitive values. A string under a known sensitive key is
// truncated regardless of depth; every other string is still run through
// maskSecretKey so `sk_*` keys are masked wherever they appear.
const redactSensitive = (value: unknown, key?: string): unknown => {
if (key && SENSITIVE_KEYS.has(key) && typeof value === 'string') {
return value.substring(0, 7);
}

if (Array.isArray(value)) {
return value.map(item => redactSensitive(item));
}

if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactSensitive(v, k)]));
}

return maskSecretKey(value);
};

export const logFormatter = (entry: LogEntry) => {
return (Array.isArray(entry) ? entry : [entry])
.map(entry => {
if (typeof entry === 'string') {
return maskSecretKey(entry);
}

const masked = Object.fromEntries(Object.entries(entry).map(([k, v]) => [k, maskSecretKey(v)]));
return JSON.stringify(masked, null, 2);
return JSON.stringify(redactSensitive(entry), null, 2);
})
.join(', ');
};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): harden machine-token verification and redact debug-logged tokens by jacekradko · Pull Request #8744 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/machine-token-sub-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Prevent an unhandled exception when verifying a machine token whose JWT payload has a missing or non-string `sub`. Such tokens are now classified and rejected with a typed verification error instead of throwing, so a crafted `Authorization` header can no longer surface as an unhandled error during request authentication.
5 changes: 5 additions & 0 deletions .changeset/redact-nested-tokens-debug-formatter.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Harden middleware debug log output: the formatter now recursively truncates known credential keys (`sessionToken`, `tokenInHeader`, `sessionTokenInCookie`, `secretKey`, `jwtKey`) at any nesting depth, so a bearer token can no longer reach the logs even if a debug producer nests one. This is a defense-in-depth backstop alongside the source-level redaction in `@clerk/backend`.
5 changes: 5 additions & 0 deletions .changeset/redact-tokens-debug-output.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Redact raw bearer credentials from the `auth` object's debug output. The debug payload (surfaced when an SDK enables middleware debug logging) previously included full session, machine, refresh, dev-browser and handshake tokens; each now exposes only a short, non-reconstructable prefix, matching how `secretKey` and `jwtKey` are already handled.
30 changes: 30 additions & 0 deletions packages/backend/src/tokens/__tests__/authObjects.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('signedInAuthObject', () => {
expect(token).toBe('token');
});

it('redacts raw session and machine tokens from debug output', () => {
const rawSessionToken = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.this-segment-must-never-be-logged';
const mockAuthenticateContext = {
tokenInHeader: 'eyJhbGciOiJSUzI1NiJ9.header-bearer.this-segment-must-never-be-logged',
sessionTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.cookie-bearer.this-segment-must-never-be-logged',
refreshTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.refresh-bearer.this-segment-must-never-be-logged',
devBrowserToken: 'eyJhbGciOiJSUzI1NiJ9.devbrowser-bearer.this-segment-must-never-be-logged',
handshakeToken: 'eyJhbGciOiJSUzI1NiJ9.handshake-bearer.this-segment-must-never-be-logged',
} as unknown as AuthenticateContext;

const authObject = signedInAuthObject(mockAuthenticateContext, rawSessionToken, {
sub: 'userId',
} as unknown as JwtPayload);

const debug = authObject.debug() as Record<string, string>;

// Only a short, non-reconstructable prefix of each bearer credential is exposed.
expect(debug.sessionToken).toBe('eyJhbGc');
expect(debug.tokenInHeader).toBe('eyJhbGc');
expect(debug.sessionTokenInCookie).toBe('eyJhbGc');
expect(debug.refreshTokenInCookie).toBe('eyJhbGc');
expect(debug.devBrowserToken).toBe('eyJhbGc');
expect(debug.handshakeToken).toBe('eyJhbGc');

// The full tokens must not be recoverable from the serialized debug payload.
const serialized = JSON.stringify(debug);
expect(serialized).not.toContain('this-segment-must-never-be-logged');
expect(serialized).not.toContain(rawSessionToken);
});

describe('JWT v1', () => {
it('has() for user scope', () => {
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
Expand Down
39 changes: 39 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -509,6 +509,45 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
expect(result.errors).toBeDefined();
expect(result.errors?.[0].message).toContain('expired');
});

// Regression: `decodedResult.payload.sub.startsWith(...)` previously threw a
// TypeError for a missing or non-string `sub` before OAuth verification ran, so a
// crafted at+jwt bearer token surfaced as an unhandled error in request auth.
it.each([
['a missing', undefined],
['a null', null],
['a numeric', 123],
['an object', {}],
] as Array<[string, unknown]>)(
'classifies an at+jwt token with %s sub as OAuth instead of throwing',
async (_label, sub) => {
server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => {
return HttpResponse.json(mockJwks);
}),
),
);

const payload: Record<string, unknown> = { ...mockOAuthAccessTokenJwtPayload };
if (sub === undefined) {
delete payload.sub;
} else {
payload.sub = sub;
}

const oauthJwt = await createSignedOAuthJwt(payload as typeof mockOAuthAccessTokenJwtPayload, 'at+jwt');

const result = await verifyMachineAuthToken(oauthJwt, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
});

// Reaching a typed OAuth result proves the M2M `sub` check no longer throws.
expect(result.tokenType).toBe('oauth_token');
},
);
});

describe('verifyM2MToken with JWT', () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/tokens/authObjects.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,19 @@ const createDebug = (data: AuthObjectDebugData | undefined) => {
const res = { ...data };
res.secretKey = (res.secretKey || '').substring(0, 7);
res.jwtKey = (res.jwtKey || '').substring(0, 7);
// Session and machine tokens are live bearer credentials, so only ever expose a
// short, non-reconstructable prefix here, the same way secretKey/jwtKey are handled
// above. Otherwise enabling debug logging would write usable tokens to logs.
// This also covers the bearer fields carried on AuthenticateContext, which is spread
// wholesale into the debug payload by signedInAuthObject: the refresh token is the
// most sensitive of these, and the dev-browser/handshake tokens are short-lived but
// still credentials.
res.sessionToken = (res.sessionToken || '').substring(0, 7);
res.tokenInHeader = (res.tokenInHeader || '').substring(0, 7);
res.sessionTokenInCookie = (res.sessionTokenInCookie || '').substring(0, 7);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.refreshTokenInCookie = (res.refreshTokenInCookie || '').substring(0, 7);
res.devBrowserToken = (res.devBrowserToken || '').substring(0, 7);
res.handshakeToken = (res.handshakeToken || '').substring(0, 7);
return { ...res };
};
};
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,7 +261,7 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken
} as MachineTokenReturnType<never, MachineTokenVerificationError>;
}

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);
}

Expand Down
38 changes: 38 additions & 0 deletions packages/nextjs/src/utils/__tests__/logFormatter.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';

import { logFormatter } from '../logFormatter';

describe('logFormatter', () => {
it('truncates sensitive token keys nested in debug objects', () => {
const entry = [
'auth',
{
auth: { userId: 'user_123' },
debug: {
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',
},
},
];

const output = logFormatter(entry as any);

// Full bearer tokens nested under known keys must not survive formatting.
expect(output).not.toContain('full-session-segment-should-not-appear');
expect(output).not.toContain('header-segment-should-not-appear');
expect(output).not.toContain('cookie-segment-should-not-appear');
// Only the short, non-reconstructable prefix remains.
expect(output).toContain('"sessionToken": "eyJhbGc"');
// Non-sensitive nested data is preserved.
expect(output).toContain('"userId": "user_123"');
});

it('is idempotent for values already truncated at the source', () => {
const entry = ['auth', { debug: { sessionToken: 'eyJhbGc' } }];

const output = logFormatter(entry as any);

expect(output).toContain('"sessionToken": "eyJhbGc"');
});
});
29 changes: 27 additions & 2 deletions packages/nextjs/src/utils/logFormatter.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import type { LogEntry } from './debugLogger';

// Keys whose values are live bearer credentials or secrets. Their values are
// truncated at any nesting depth, as a defense-in-depth backstop for debug
// producers that nest sensitive data. The authoritative redaction still happens
// at the source (e.g. @clerk/backend's auth-object debug output already truncates
// these); truncating to the same 7-char prefix here keeps that output stable.
const SENSITIVE_KEYS = new Set(['sessionToken', 'tokenInHeader', 'sessionTokenInCookie', 'secretKey', 'jwtKey']);

// Move to shared once clerk/shared is used in clerk/nextjs
const maskSecretKey = (str: any) => {
if (!str || typeof str !== 'string') {
Expand All@@ -13,15 +20,33 @@ const maskSecretKey = (str: any) => {
}
};

// Recursively redacts sensitive values. A string under a known sensitive key is
// truncated regardless of depth; every other string is still run through
// maskSecretKey so `sk_*` keys are masked wherever they appear.
const redactSensitive = (value: unknown, key?: string): unknown => {
if (key && SENSITIVE_KEYS.has(key) && typeof value === 'string') {
return value.substring(0, 7);
}

if (Array.isArray(value)) {
return value.map(item => redactSensitive(item));
}

if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactSensitive(v, k)]));
}

return maskSecretKey(value);
};

export const logFormatter = (entry: LogEntry) => {
return (Array.isArray(entry) ? entry : [entry])
.map(entry => {
if (typeof entry === 'string') {
return maskSecretKey(entry);
}

const masked = Object.fromEntries(Object.entries(entry).map(([k, v]) => [k, maskSecretKey(v)]));
return JSON.stringify(masked, null, 2);
return JSON.stringify(redactSensitive(entry), null, 2);
})
.join(', ');
};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(backend): harden machine-token verification and redact debug-logged tokens by jacekradko · Pull Request #8744 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/machine-token-sub-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Prevent an unhandled exception when verifying a machine token whose JWT payload has a missing or non-string `sub`. Such tokens are now classified and rejected with a typed verification error instead of throwing, so a crafted `Authorization` header can no longer surface as an unhandled error during request authentication.
5 changes: 5 additions & 0 deletions .changeset/redact-nested-tokens-debug-formatter.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Harden middleware debug log output: the formatter now recursively truncates known credential keys (`sessionToken`, `tokenInHeader`, `sessionTokenInCookie`, `secretKey`, `jwtKey`) at any nesting depth, so a bearer token can no longer reach the logs even if a debug producer nests one. This is a defense-in-depth backstop alongside the source-level redaction in `@clerk/backend`.
5 changes: 5 additions & 0 deletions .changeset/redact-tokens-debug-output.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Redact raw bearer credentials from the `auth` object's debug output. The debug payload (surfaced when an SDK enables middleware debug logging) previously included full session, machine, refresh, dev-browser and handshake tokens; each now exposes only a short, non-reconstructable prefix, matching how `secretKey` and `jwtKey` are already handled.
30 changes: 30 additions & 0 deletions packages/backend/src/tokens/__tests__/authObjects.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('signedInAuthObject', () => {
expect(token).toBe('token');
});

it('redacts raw session and machine tokens from debug output', () => {
const rawSessionToken = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.this-segment-must-never-be-logged';
const mockAuthenticateContext = {
tokenInHeader: 'eyJhbGciOiJSUzI1NiJ9.header-bearer.this-segment-must-never-be-logged',
sessionTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.cookie-bearer.this-segment-must-never-be-logged',
refreshTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.refresh-bearer.this-segment-must-never-be-logged',
devBrowserToken: 'eyJhbGciOiJSUzI1NiJ9.devbrowser-bearer.this-segment-must-never-be-logged',
handshakeToken: 'eyJhbGciOiJSUzI1NiJ9.handshake-bearer.this-segment-must-never-be-logged',
} as unknown as AuthenticateContext;

const authObject = signedInAuthObject(mockAuthenticateContext, rawSessionToken, {
sub: 'userId',
} as unknown as JwtPayload);

const debug = authObject.debug() as Record<string, string>;

// Only a short, non-reconstructable prefix of each bearer credential is exposed.
expect(debug.sessionToken).toBe('eyJhbGc');
expect(debug.tokenInHeader).toBe('eyJhbGc');
expect(debug.sessionTokenInCookie).toBe('eyJhbGc');
expect(debug.refreshTokenInCookie).toBe('eyJhbGc');
expect(debug.devBrowserToken).toBe('eyJhbGc');
expect(debug.handshakeToken).toBe('eyJhbGc');

// The full tokens must not be recoverable from the serialized debug payload.
const serialized = JSON.stringify(debug);
expect(serialized).not.toContain('this-segment-must-never-be-logged');
expect(serialized).not.toContain(rawSessionToken);
});

describe('JWT v1', () => {
it('has() for user scope', () => {
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
Expand Down
39 changes: 39 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -509,6 +509,45 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
expect(result.errors).toBeDefined();
expect(result.errors?.[0].message).toContain('expired');
});

// Regression: `decodedResult.payload.sub.startsWith(...)` previously threw a
// TypeError for a missing or non-string `sub` before OAuth verification ran, so a
// crafted at+jwt bearer token surfaced as an unhandled error in request auth.
it.each([
['a missing', undefined],
['a null', null],
['a numeric', 123],
['an object', {}],
] as Array<[string, unknown]>)(
'classifies an at+jwt token with %s sub as OAuth instead of throwing',
async (_label, sub) => {
server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => {
return HttpResponse.json(mockJwks);
}),
),
);

const payload: Record<string, unknown> = { ...mockOAuthAccessTokenJwtPayload };
if (sub === undefined) {
delete payload.sub;
} else {
payload.sub = sub;
}

const oauthJwt = await createSignedOAuthJwt(payload as typeof mockOAuthAccessTokenJwtPayload, 'at+jwt');

const result = await verifyMachineAuthToken(oauthJwt, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
});

// Reaching a typed OAuth result proves the M2M `sub` check no longer throws.
expect(result.tokenType).toBe('oauth_token');
},
);
});

describe('verifyM2MToken with JWT', () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/tokens/authObjects.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,19 @@ const createDebug = (data: AuthObjectDebugData | undefined) => {
const res = { ...data };
res.secretKey = (res.secretKey || '').substring(0, 7);
res.jwtKey = (res.jwtKey || '').substring(0, 7);
// Session and machine tokens are live bearer credentials, so only ever expose a
// short, non-reconstructable prefix here, the same way secretKey/jwtKey are handled
// above. Otherwise enabling debug logging would write usable tokens to logs.
// This also covers the bearer fields carried on AuthenticateContext, which is spread
// wholesale into the debug payload by signedInAuthObject: the refresh token is the
// most sensitive of these, and the dev-browser/handshake tokens are short-lived but
// still credentials.
res.sessionToken = (res.sessionToken || '').substring(0, 7);
res.tokenInHeader = (res.tokenInHeader || '').substring(0, 7);
res.sessionTokenInCookie = (res.sessionTokenInCookie || '').substring(0, 7);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.refreshTokenInCookie = (res.refreshTokenInCookie || '').substring(0, 7);
res.devBrowserToken = (res.devBrowserToken || '').substring(0, 7);
res.handshakeToken = (res.handshakeToken || '').substring(0, 7);
return { ...res };
};
};
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,7 +261,7 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken
} as MachineTokenReturnType<never, MachineTokenVerificationError>;
}

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);
}

Expand Down
38 changes: 38 additions & 0 deletions packages/nextjs/src/utils/__tests__/logFormatter.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';

import { logFormatter } from '../logFormatter';

describe('logFormatter', () => {
it('truncates sensitive token keys nested in debug objects', () => {
const entry = [
'auth',
{
auth: { userId: 'user_123' },
debug: {
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',
},
},
];

const output = logFormatter(entry as any);

// Full bearer tokens nested under known keys must not survive formatting.
expect(output).not.toContain('full-session-segment-should-not-appear');
expect(output).not.toContain('header-segment-should-not-appear');
expect(output).not.toContain('cookie-segment-should-not-appear');
// Only the short, non-reconstructable prefix remains.
expect(output).toContain('"sessionToken": "eyJhbGc"');
// Non-sensitive nested data is preserved.
expect(output).toContain('"userId": "user_123"');
});

it('is idempotent for values already truncated at the source', () => {
const entry = ['auth', { debug: { sessionToken: 'eyJhbGc' } }];

const output = logFormatter(entry as any);

expect(output).toContain('"sessionToken": "eyJhbGc"');
});
});
29 changes: 27 additions & 2 deletions packages/nextjs/src/utils/logFormatter.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import type { LogEntry } from './debugLogger';

// Keys whose values are live bearer credentials or secrets. Their values are
// truncated at any nesting depth, as a defense-in-depth backstop for debug
// producers that nest sensitive data. The authoritative redaction still happens
// at the source (e.g. @clerk/backend's auth-object debug output already truncates
// these); truncating to the same 7-char prefix here keeps that output stable.
const SENSITIVE_KEYS = new Set(['sessionToken', 'tokenInHeader', 'sessionTokenInCookie', 'secretKey', 'jwtKey']);

// Move to shared once clerk/shared is used in clerk/nextjs
const maskSecretKey = (str: any) => {
if (!str || typeof str !== 'string') {
Expand All@@ -13,15 +20,33 @@ const maskSecretKey = (str: any) => {
}
};

// Recursively redacts sensitive values. A string under a known sensitive key is
// truncated regardless of depth; every other string is still run through
// maskSecretKey so `sk_*` keys are masked wherever they appear.
const redactSensitive = (value: unknown, key?: string): unknown => {
if (key && SENSITIVE_KEYS.has(key) && typeof value === 'string') {
return value.substring(0, 7);
}

if (Array.isArray(value)) {
return value.map(item => redactSensitive(item));
}

if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactSensitive(v, k)]));
}

return maskSecretKey(value);
};

export const logFormatter = (entry: LogEntry) => {
return (Array.isArray(entry) ? entry : [entry])
.map(entry => {
if (typeof entry === 'string') {
return maskSecretKey(entry);
}

const masked = Object.fromEntries(Object.entries(entry).map(([k, v]) => [k, maskSecretKey(v)]));
return JSON.stringify(masked, null, 2);
return JSON.stringify(redactSensitive(entry), null, 2);
})
.join(', ');
};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): harden machine-token verification and redact debug-logged tokens by jacekradko · Pull Request #8744 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/machine-token-sub-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Prevent an unhandled exception when verifying a machine token whose JWT payload has a missing or non-string `sub`. Such tokens are now classified and rejected with a typed verification error instead of throwing, so a crafted `Authorization` header can no longer surface as an unhandled error during request authentication.
5 changes: 5 additions & 0 deletions .changeset/redact-nested-tokens-debug-formatter.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Harden middleware debug log output: the formatter now recursively truncates known credential keys (`sessionToken`, `tokenInHeader`, `sessionTokenInCookie`, `secretKey`, `jwtKey`) at any nesting depth, so a bearer token can no longer reach the logs even if a debug producer nests one. This is a defense-in-depth backstop alongside the source-level redaction in `@clerk/backend`.
5 changes: 5 additions & 0 deletions .changeset/redact-tokens-debug-output.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Redact raw bearer credentials from the `auth` object's debug output. The debug payload (surfaced when an SDK enables middleware debug logging) previously included full session, machine, refresh, dev-browser and handshake tokens; each now exposes only a short, non-reconstructable prefix, matching how `secretKey` and `jwtKey` are already handled.
30 changes: 30 additions & 0 deletions packages/backend/src/tokens/__tests__/authObjects.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('signedInAuthObject', () => {
expect(token).toBe('token');
});

it('redacts raw session and machine tokens from debug output', () => {
const rawSessionToken = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.this-segment-must-never-be-logged';
const mockAuthenticateContext = {
tokenInHeader: 'eyJhbGciOiJSUzI1NiJ9.header-bearer.this-segment-must-never-be-logged',
sessionTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.cookie-bearer.this-segment-must-never-be-logged',
refreshTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.refresh-bearer.this-segment-must-never-be-logged',
devBrowserToken: 'eyJhbGciOiJSUzI1NiJ9.devbrowser-bearer.this-segment-must-never-be-logged',
handshakeToken: 'eyJhbGciOiJSUzI1NiJ9.handshake-bearer.this-segment-must-never-be-logged',
} as unknown as AuthenticateContext;

const authObject = signedInAuthObject(mockAuthenticateContext, rawSessionToken, {
sub: 'userId',
} as unknown as JwtPayload);

const debug = authObject.debug() as Record<string, string>;

// Only a short, non-reconstructable prefix of each bearer credential is exposed.
expect(debug.sessionToken).toBe('eyJhbGc');
expect(debug.tokenInHeader).toBe('eyJhbGc');
expect(debug.sessionTokenInCookie).toBe('eyJhbGc');
expect(debug.refreshTokenInCookie).toBe('eyJhbGc');
expect(debug.devBrowserToken).toBe('eyJhbGc');
expect(debug.handshakeToken).toBe('eyJhbGc');

// The full tokens must not be recoverable from the serialized debug payload.
const serialized = JSON.stringify(debug);
expect(serialized).not.toContain('this-segment-must-never-be-logged');
expect(serialized).not.toContain(rawSessionToken);
});

describe('JWT v1', () => {
it('has() for user scope', () => {
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
Expand Down
39 changes: 39 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -509,6 +509,45 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
expect(result.errors).toBeDefined();
expect(result.errors?.[0].message).toContain('expired');
});

// Regression: `decodedResult.payload.sub.startsWith(...)` previously threw a
// TypeError for a missing or non-string `sub` before OAuth verification ran, so a
// crafted at+jwt bearer token surfaced as an unhandled error in request auth.
it.each([
['a missing', undefined],
['a null', null],
['a numeric', 123],
['an object', {}],
] as Array<[string, unknown]>)(
'classifies an at+jwt token with %s sub as OAuth instead of throwing',
async (_label, sub) => {
server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => {
return HttpResponse.json(mockJwks);
}),
),
);

const payload: Record<string, unknown> = { ...mockOAuthAccessTokenJwtPayload };
if (sub === undefined) {
delete payload.sub;
} else {
payload.sub = sub;
}

const oauthJwt = await createSignedOAuthJwt(payload as typeof mockOAuthAccessTokenJwtPayload, 'at+jwt');

const result = await verifyMachineAuthToken(oauthJwt, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
});

// Reaching a typed OAuth result proves the M2M `sub` check no longer throws.
expect(result.tokenType).toBe('oauth_token');
},
);
});

describe('verifyM2MToken with JWT', () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/tokens/authObjects.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,19 @@ const createDebug = (data: AuthObjectDebugData | undefined) => {
const res = { ...data };
res.secretKey = (res.secretKey || '').substring(0, 7);
res.jwtKey = (res.jwtKey || '').substring(0, 7);
// Session and machine tokens are live bearer credentials, so only ever expose a
// short, non-reconstructable prefix here, the same way secretKey/jwtKey are handled
// above. Otherwise enabling debug logging would write usable tokens to logs.
// This also covers the bearer fields carried on AuthenticateContext, which is spread
// wholesale into the debug payload by signedInAuthObject: the refresh token is the
// most sensitive of these, and the dev-browser/handshake tokens are short-lived but
// still credentials.
res.sessionToken = (res.sessionToken || '').substring(0, 7);
res.tokenInHeader = (res.tokenInHeader || '').substring(0, 7);
res.sessionTokenInCookie = (res.sessionTokenInCookie || '').substring(0, 7);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.refreshTokenInCookie = (res.refreshTokenInCookie || '').substring(0, 7);
res.devBrowserToken = (res.devBrowserToken || '').substring(0, 7);
res.handshakeToken = (res.handshakeToken || '').substring(0, 7);
return { ...res };
};
};
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,7 +261,7 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken
} as MachineTokenReturnType<never, MachineTokenVerificationError>;
}

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);
}

Expand Down
38 changes: 38 additions & 0 deletions packages/nextjs/src/utils/__tests__/logFormatter.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';

import { logFormatter } from '../logFormatter';

describe('logFormatter', () => {
it('truncates sensitive token keys nested in debug objects', () => {
const entry = [
'auth',
{
auth: { userId: 'user_123' },
debug: {
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',
},
},
];

const output = logFormatter(entry as any);

// Full bearer tokens nested under known keys must not survive formatting.
expect(output).not.toContain('full-session-segment-should-not-appear');
expect(output).not.toContain('header-segment-should-not-appear');
expect(output).not.toContain('cookie-segment-should-not-appear');
// Only the short, non-reconstructable prefix remains.
expect(output).toContain('"sessionToken": "eyJhbGc"');
// Non-sensitive nested data is preserved.
expect(output).toContain('"userId": "user_123"');
});

it('is idempotent for values already truncated at the source', () => {
const entry = ['auth', { debug: { sessionToken: 'eyJhbGc' } }];

const output = logFormatter(entry as any);

expect(output).toContain('"sessionToken": "eyJhbGc"');
});
});
29 changes: 27 additions & 2 deletions packages/nextjs/src/utils/logFormatter.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import type { LogEntry } from './debugLogger';

// Keys whose values are live bearer credentials or secrets. Their values are
// truncated at any nesting depth, as a defense-in-depth backstop for debug
// producers that nest sensitive data. The authoritative redaction still happens
// at the source (e.g. @clerk/backend's auth-object debug output already truncates
// these); truncating to the same 7-char prefix here keeps that output stable.
const SENSITIVE_KEYS = new Set(['sessionToken', 'tokenInHeader', 'sessionTokenInCookie', 'secretKey', 'jwtKey']);

// Move to shared once clerk/shared is used in clerk/nextjs
const maskSecretKey = (str: any) => {
if (!str || typeof str !== 'string') {
Expand All@@ -13,15 +20,33 @@ const maskSecretKey = (str: any) => {
}
};

// Recursively redacts sensitive values. A string under a known sensitive key is
// truncated regardless of depth; every other string is still run through
// maskSecretKey so `sk_*` keys are masked wherever they appear.
const redactSensitive = (value: unknown, key?: string): unknown => {
if (key && SENSITIVE_KEYS.has(key) && typeof value === 'string') {
return value.substring(0, 7);
}

if (Array.isArray(value)) {
return value.map(item => redactSensitive(item));
}

if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactSensitive(v, k)]));
}

return maskSecretKey(value);
};

export const logFormatter = (entry: LogEntry) => {
return (Array.isArray(entry) ? entry : [entry])
.map(entry => {
if (typeof entry === 'string') {
return maskSecretKey(entry);
}

const masked = Object.fromEntries(Object.entries(entry).map(([k, v]) => [k, maskSecretKey(v)]));
return JSON.stringify(masked, null, 2);
return JSON.stringify(redactSensitive(entry), null, 2);
})
.join(', ');
};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): harden machine-token verification and redact debug-logged tokens by jacekradko · Pull Request #8744 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/machine-token-sub-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Prevent an unhandled exception when verifying a machine token whose JWT payload has a missing or non-string `sub`. Such tokens are now classified and rejected with a typed verification error instead of throwing, so a crafted `Authorization` header can no longer surface as an unhandled error during request authentication.
5 changes: 5 additions & 0 deletions .changeset/redact-nested-tokens-debug-formatter.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Harden middleware debug log output: the formatter now recursively truncates known credential keys (`sessionToken`, `tokenInHeader`, `sessionTokenInCookie`, `secretKey`, `jwtKey`) at any nesting depth, so a bearer token can no longer reach the logs even if a debug producer nests one. This is a defense-in-depth backstop alongside the source-level redaction in `@clerk/backend`.
5 changes: 5 additions & 0 deletions .changeset/redact-tokens-debug-output.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Redact raw bearer credentials from the `auth` object's debug output. The debug payload (surfaced when an SDK enables middleware debug logging) previously included full session, machine, refresh, dev-browser and handshake tokens; each now exposes only a short, non-reconstructable prefix, matching how `secretKey` and `jwtKey` are already handled.
30 changes: 30 additions & 0 deletions packages/backend/src/tokens/__tests__/authObjects.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('signedInAuthObject', () => {
expect(token).toBe('token');
});

it('redacts raw session and machine tokens from debug output', () => {
const rawSessionToken = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.this-segment-must-never-be-logged';
const mockAuthenticateContext = {
tokenInHeader: 'eyJhbGciOiJSUzI1NiJ9.header-bearer.this-segment-must-never-be-logged',
sessionTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.cookie-bearer.this-segment-must-never-be-logged',
refreshTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.refresh-bearer.this-segment-must-never-be-logged',
devBrowserToken: 'eyJhbGciOiJSUzI1NiJ9.devbrowser-bearer.this-segment-must-never-be-logged',
handshakeToken: 'eyJhbGciOiJSUzI1NiJ9.handshake-bearer.this-segment-must-never-be-logged',
} as unknown as AuthenticateContext;

const authObject = signedInAuthObject(mockAuthenticateContext, rawSessionToken, {
sub: 'userId',
} as unknown as JwtPayload);

const debug = authObject.debug() as Record<string, string>;

// Only a short, non-reconstructable prefix of each bearer credential is exposed.
expect(debug.sessionToken).toBe('eyJhbGc');
expect(debug.tokenInHeader).toBe('eyJhbGc');
expect(debug.sessionTokenInCookie).toBe('eyJhbGc');
expect(debug.refreshTokenInCookie).toBe('eyJhbGc');
expect(debug.devBrowserToken).toBe('eyJhbGc');
expect(debug.handshakeToken).toBe('eyJhbGc');

// The full tokens must not be recoverable from the serialized debug payload.
const serialized = JSON.stringify(debug);
expect(serialized).not.toContain('this-segment-must-never-be-logged');
expect(serialized).not.toContain(rawSessionToken);
});

describe('JWT v1', () => {
it('has() for user scope', () => {
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
Expand Down
39 changes: 39 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -509,6 +509,45 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
expect(result.errors).toBeDefined();
expect(result.errors?.[0].message).toContain('expired');
});

// Regression: `decodedResult.payload.sub.startsWith(...)` previously threw a
// TypeError for a missing or non-string `sub` before OAuth verification ran, so a
// crafted at+jwt bearer token surfaced as an unhandled error in request auth.
it.each([
['a missing', undefined],
['a null', null],
['a numeric', 123],
['an object', {}],
] as Array<[string, unknown]>)(
'classifies an at+jwt token with %s sub as OAuth instead of throwing',
async (_label, sub) => {
server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => {
return HttpResponse.json(mockJwks);
}),
),
);

const payload: Record<string, unknown> = { ...mockOAuthAccessTokenJwtPayload };
if (sub === undefined) {
delete payload.sub;
} else {
payload.sub = sub;
}

const oauthJwt = await createSignedOAuthJwt(payload as typeof mockOAuthAccessTokenJwtPayload, 'at+jwt');

const result = await verifyMachineAuthToken(oauthJwt, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
});

// Reaching a typed OAuth result proves the M2M `sub` check no longer throws.
expect(result.tokenType).toBe('oauth_token');
},
);
});

describe('verifyM2MToken with JWT', () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/tokens/authObjects.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,19 @@ const createDebug = (data: AuthObjectDebugData | undefined) => {
const res = { ...data };
res.secretKey = (res.secretKey || '').substring(0, 7);
res.jwtKey = (res.jwtKey || '').substring(0, 7);
// Session and machine tokens are live bearer credentials, so only ever expose a
// short, non-reconstructable prefix here, the same way secretKey/jwtKey are handled
// above. Otherwise enabling debug logging would write usable tokens to logs.
// This also covers the bearer fields carried on AuthenticateContext, which is spread
// wholesale into the debug payload by signedInAuthObject: the refresh token is the
// most sensitive of these, and the dev-browser/handshake tokens are short-lived but
// still credentials.
res.sessionToken = (res.sessionToken || '').substring(0, 7);
res.tokenInHeader = (res.tokenInHeader || '').substring(0, 7);
res.sessionTokenInCookie = (res.sessionTokenInCookie || '').substring(0, 7);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.refreshTokenInCookie = (res.refreshTokenInCookie || '').substring(0, 7);
res.devBrowserToken = (res.devBrowserToken || '').substring(0, 7);
res.handshakeToken = (res.handshakeToken || '').substring(0, 7);
return { ...res };
};
};
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,7 +261,7 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken
} as MachineTokenReturnType<never, MachineTokenVerificationError>;
}

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);
}

Expand Down
38 changes: 38 additions & 0 deletions packages/nextjs/src/utils/__tests__/logFormatter.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';

import { logFormatter } from '../logFormatter';

describe('logFormatter', () => {
it('truncates sensitive token keys nested in debug objects', () => {
const entry = [
'auth',
{
auth: { userId: 'user_123' },
debug: {
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',
},
},
];

const output = logFormatter(entry as any);

// Full bearer tokens nested under known keys must not survive formatting.
expect(output).not.toContain('full-session-segment-should-not-appear');
expect(output).not.toContain('header-segment-should-not-appear');
expect(output).not.toContain('cookie-segment-should-not-appear');
// Only the short, non-reconstructable prefix remains.
expect(output).toContain('"sessionToken": "eyJhbGc"');
// Non-sensitive nested data is preserved.
expect(output).toContain('"userId": "user_123"');
});

it('is idempotent for values already truncated at the source', () => {
const entry = ['auth', { debug: { sessionToken: 'eyJhbGc' } }];

const output = logFormatter(entry as any);

expect(output).toContain('"sessionToken": "eyJhbGc"');
});
});
29 changes: 27 additions & 2 deletions packages/nextjs/src/utils/logFormatter.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import type { LogEntry } from './debugLogger';

// Keys whose values are live bearer credentials or secrets. Their values are
// truncated at any nesting depth, as a defense-in-depth backstop for debug
// producers that nest sensitive data. The authoritative redaction still happens
// at the source (e.g. @clerk/backend's auth-object debug output already truncates
// these); truncating to the same 7-char prefix here keeps that output stable.
const SENSITIVE_KEYS = new Set(['sessionToken', 'tokenInHeader', 'sessionTokenInCookie', 'secretKey', 'jwtKey']);

// Move to shared once clerk/shared is used in clerk/nextjs
const maskSecretKey = (str: any) => {
if (!str || typeof str !== 'string') {
Expand All@@ -13,15 +20,33 @@ const maskSecretKey = (str: any) => {
}
};

// Recursively redacts sensitive values. A string under a known sensitive key is
// truncated regardless of depth; every other string is still run through
// maskSecretKey so `sk_*` keys are masked wherever they appear.
const redactSensitive = (value: unknown, key?: string): unknown => {
if (key && SENSITIVE_KEYS.has(key) && typeof value === 'string') {
return value.substring(0, 7);
}

if (Array.isArray(value)) {
return value.map(item => redactSensitive(item));
}

if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactSensitive(v, k)]));
}

return maskSecretKey(value);
};

export const logFormatter = (entry: LogEntry) => {
return (Array.isArray(entry) ? entry : [entry])
.map(entry => {
if (typeof entry === 'string') {
return maskSecretKey(entry);
}

const masked = Object.fromEntries(Object.entries(entry).map(([k, v]) => [k, maskSecretKey(v)]));
return JSON.stringify(masked, null, 2);
return JSON.stringify(redactSensitive(entry), null, 2);
})
.join(', ');
};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(backend): harden machine-token verification and redact debug-logged tokens by jacekradko · Pull Request #8744 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/machine-token-sub-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Prevent an unhandled exception when verifying a machine token whose JWT payload has a missing or non-string `sub`. Such tokens are now classified and rejected with a typed verification error instead of throwing, so a crafted `Authorization` header can no longer surface as an unhandled error during request authentication.
5 changes: 5 additions & 0 deletions .changeset/redact-nested-tokens-debug-formatter.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Harden middleware debug log output: the formatter now recursively truncates known credential keys (`sessionToken`, `tokenInHeader`, `sessionTokenInCookie`, `secretKey`, `jwtKey`) at any nesting depth, so a bearer token can no longer reach the logs even if a debug producer nests one. This is a defense-in-depth backstop alongside the source-level redaction in `@clerk/backend`.
5 changes: 5 additions & 0 deletions .changeset/redact-tokens-debug-output.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Redact raw bearer credentials from the `auth` object's debug output. The debug payload (surfaced when an SDK enables middleware debug logging) previously included full session, machine, refresh, dev-browser and handshake tokens; each now exposes only a short, non-reconstructable prefix, matching how `secretKey` and `jwtKey` are already handled.
30 changes: 30 additions & 0 deletions packages/backend/src/tokens/__tests__/authObjects.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('signedInAuthObject', () => {
expect(token).toBe('token');
});

it('redacts raw session and machine tokens from debug output', () => {
const rawSessionToken = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.this-segment-must-never-be-logged';
const mockAuthenticateContext = {
tokenInHeader: 'eyJhbGciOiJSUzI1NiJ9.header-bearer.this-segment-must-never-be-logged',
sessionTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.cookie-bearer.this-segment-must-never-be-logged',
refreshTokenInCookie: 'eyJhbGciOiJSUzI1NiJ9.refresh-bearer.this-segment-must-never-be-logged',
devBrowserToken: 'eyJhbGciOiJSUzI1NiJ9.devbrowser-bearer.this-segment-must-never-be-logged',
handshakeToken: 'eyJhbGciOiJSUzI1NiJ9.handshake-bearer.this-segment-must-never-be-logged',
} as unknown as AuthenticateContext;

const authObject = signedInAuthObject(mockAuthenticateContext, rawSessionToken, {
sub: 'userId',
} as unknown as JwtPayload);

const debug = authObject.debug() as Record<string, string>;

// Only a short, non-reconstructable prefix of each bearer credential is exposed.
expect(debug.sessionToken).toBe('eyJhbGc');
expect(debug.tokenInHeader).toBe('eyJhbGc');
expect(debug.sessionTokenInCookie).toBe('eyJhbGc');
expect(debug.refreshTokenInCookie).toBe('eyJhbGc');
expect(debug.devBrowserToken).toBe('eyJhbGc');
expect(debug.handshakeToken).toBe('eyJhbGc');

// The full tokens must not be recoverable from the serialized debug payload.
const serialized = JSON.stringify(debug);
expect(serialized).not.toContain('this-segment-must-never-be-logged');
expect(serialized).not.toContain(rawSessionToken);
});

describe('JWT v1', () => {
it('has() for user scope', () => {
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
Expand Down
39 changes: 39 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -509,6 +509,45 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
expect(result.errors).toBeDefined();
expect(result.errors?.[0].message).toContain('expired');
});

// Regression: `decodedResult.payload.sub.startsWith(...)` previously threw a
// TypeError for a missing or non-string `sub` before OAuth verification ran, so a
// crafted at+jwt bearer token surfaced as an unhandled error in request auth.
it.each([
['a missing', undefined],
['a null', null],
['a numeric', 123],
['an object', {}],
] as Array<[string, unknown]>)(
'classifies an at+jwt token with %s sub as OAuth instead of throwing',
async (_label, sub) => {
server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => {
return HttpResponse.json(mockJwks);
}),
),
);

const payload: Record<string, unknown> = { ...mockOAuthAccessTokenJwtPayload };
if (sub === undefined) {
delete payload.sub;
} else {
payload.sub = sub;
}

const oauthJwt = await createSignedOAuthJwt(payload as typeof mockOAuthAccessTokenJwtPayload, 'at+jwt');

const result = await verifyMachineAuthToken(oauthJwt, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
});

// Reaching a typed OAuth result proves the M2M `sub` check no longer throws.
expect(result.tokenType).toBe('oauth_token');
},
);
});

describe('verifyM2MToken with JWT', () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/tokens/authObjects.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,19 @@ const createDebug = (data: AuthObjectDebugData | undefined) => {
const res = { ...data };
res.secretKey = (res.secretKey || '').substring(0, 7);
res.jwtKey = (res.jwtKey || '').substring(0, 7);
// Session and machine tokens are live bearer credentials, so only ever expose a
// short, non-reconstructable prefix here, the same way secretKey/jwtKey are handled
// above. Otherwise enabling debug logging would write usable tokens to logs.
// This also covers the bearer fields carried on AuthenticateContext, which is spread
// wholesale into the debug payload by signedInAuthObject: the refresh token is the
// most sensitive of these, and the dev-browser/handshake tokens are short-lived but
// still credentials.
res.sessionToken = (res.sessionToken || '').substring(0, 7);
res.tokenInHeader = (res.tokenInHeader || '').substring(0, 7);
res.sessionTokenInCookie = (res.sessionTokenInCookie || '').substring(0, 7);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.refreshTokenInCookie = (res.refreshTokenInCookie || '').substring(0, 7);
res.devBrowserToken = (res.devBrowserToken || '').substring(0, 7);
res.handshakeToken = (res.handshakeToken || '').substring(0, 7);
return { ...res };
};
};
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,7 +261,7 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken
} as MachineTokenReturnType<never, MachineTokenVerificationError>;
}

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);
}

Expand Down
38 changes: 38 additions & 0 deletions packages/nextjs/src/utils/__tests__/logFormatter.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';

import { logFormatter } from '../logFormatter';

describe('logFormatter', () => {
it('truncates sensitive token keys nested in debug objects', () => {
const entry = [
'auth',
{
auth: { userId: 'user_123' },
debug: {
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',
},
},
];

const output = logFormatter(entry as any);

// Full bearer tokens nested under known keys must not survive formatting.
expect(output).not.toContain('full-session-segment-should-not-appear');
expect(output).not.toContain('header-segment-should-not-appear');
expect(output).not.toContain('cookie-segment-should-not-appear');
// Only the short, non-reconstructable prefix remains.
expect(output).toContain('"sessionToken": "eyJhbGc"');
// Non-sensitive nested data is preserved.
expect(output).toContain('"userId": "user_123"');
});

it('is idempotent for values already truncated at the source', () => {
const entry = ['auth', { debug: { sessionToken: 'eyJhbGc' } }];

const output = logFormatter(entry as any);

expect(output).toContain('"sessionToken": "eyJhbGc"');
});
});
29 changes: 27 additions & 2 deletions packages/nextjs/src/utils/logFormatter.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import type { LogEntry } from './debugLogger';

// Keys whose values are live bearer credentials or secrets. Their values are
// truncated at any nesting depth, as a defense-in-depth backstop for debug
// producers that nest sensitive data. The authoritative redaction still happens
// at the source (e.g. @clerk/backend's auth-object debug output already truncates
// these); truncating to the same 7-char prefix here keeps that output stable.
const SENSITIVE_KEYS = new Set(['sessionToken', 'tokenInHeader', 'sessionTokenInCookie', 'secretKey', 'jwtKey']);

// Move to shared once clerk/shared is used in clerk/nextjs
const maskSecretKey = (str: any) => {
if (!str || typeof str !== 'string') {
Expand All@@ -13,15 +20,33 @@ const maskSecretKey = (str: any) => {
}
};

// Recursively redacts sensitive values. A string under a known sensitive key is
// truncated regardless of depth; every other string is still run through
// maskSecretKey so `sk_*` keys are masked wherever they appear.
const redactSensitive = (value: unknown, key?: string): unknown => {
if (key && SENSITIVE_KEYS.has(key) && typeof value === 'string') {
return value.substring(0, 7);
}

if (Array.isArray(value)) {
return value.map(item => redactSensitive(item));
}

if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactSensitive(v, k)]));
}

return maskSecretKey(value);
};

export const logFormatter = (entry: LogEntry) => {
return (Array.isArray(entry) ? entry : [entry])
.map(entry => {
if (typeof entry === 'string') {
return maskSecretKey(entry);
}

const masked = Object.fromEntries(Object.entries(entry).map(([k, v]) => [k, maskSecretKey(v)]));
return JSON.stringify(masked, null, 2);
return JSON.stringify(redactSensitive(entry), null, 2);
})
.join(', ');
};
Loading