From 8e7577376a81310c9cca9ccbe5e835e7891494fd Mon Sep 17 00:00:00 2001 From: Jacek Date: Sun, 26 Apr 2026 07:53:38 -0500 Subject: [PATCH 1/2] fix(nextjs): Use constant-time comparison in assertTokenSignature The middleware-to-origin auth header integrity check now uses a constant-time string compare. The helper is synchronous and runtime-agnostic so it works in both Node and Edge Runtime. --- ...8-constant-time-token-signature-compare.md | 5 ++++ .../nextjs/src/server/__tests__/utils.test.ts | 30 +++++++++++++++++++ packages/nextjs/src/server/utils.ts | 16 +++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .changeset/aisec-8-constant-time-token-signature-compare.md create mode 100644 packages/nextjs/src/server/__tests__/utils.test.ts diff --git a/.changeset/aisec-8-constant-time-token-signature-compare.md b/.changeset/aisec-8-constant-time-token-signature-compare.md new file mode 100644 index 00000000000..99cb42a88a2 --- /dev/null +++ b/.changeset/aisec-8-constant-time-token-signature-compare.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Use a constant-time comparison when validating the integrity signature on the middleware-to-origin auth header handoff (`assertTokenSignature`). The previous `!==` compare was timing-variable; the new helper is synchronous and runtime-agnostic so it works in both Node and Edge Runtime. diff --git a/packages/nextjs/src/server/__tests__/utils.test.ts b/packages/nextjs/src/server/__tests__/utils.test.ts new file mode 100644 index 00000000000..5b6b6368149 --- /dev/null +++ b/packages/nextjs/src/server/__tests__/utils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { HmacSHA1 } from '../../vendor/crypto-es'; +import { assertTokenSignature } from '../utils'; + +describe('assertTokenSignature(token, key, signature)', () => { + const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLWlkIn0.0u5CllULtDVD9DUUmUMdJLbBCSNcnv4j3hCaPz4dNr8'; + const key = 'sk_test_mock'; + const validSignature = HmacSHA1(token, key).toString(); + + it('passes when the signature matches', () => { + expect(() => assertTokenSignature(token, key, validSignature)).not.toThrow(); + }); + + it('throws when the signature is missing', () => { + expect(() => assertTokenSignature(token, key, undefined)).toThrowError(); + expect(() => assertTokenSignature(token, key, null)).toThrowError(); + expect(() => assertTokenSignature(token, key, '')).toThrowError(); + }); + + it('throws when the signature differs at the last character', () => { + const tampered = validSignature.slice(0, -1) + (validSignature.endsWith('0') ? '1' : '0'); + expect(() => assertTokenSignature(token, key, tampered)).toThrowError(); + }); + + it('throws when the signature differs in length', () => { + expect(() => assertTokenSignature(token, key, validSignature.slice(0, -1))).toThrowError(); + expect(() => assertTokenSignature(token, key, validSignature + '0')).toThrowError(); + }); +}); diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index d062891e0bf..8b932d6c943 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -163,6 +163,20 @@ function createTokenSignature(token: string, key: string): string { return HmacSHA1(token, key).toString(); } +// Constant-time string equality. The signature is HMAC-SHA1 hex (fixed 40 chars), +// so the early length check leaks no secret. Synchronous and runtime-agnostic so +// it works in Edge Runtime, where node:crypto.timingSafeEqual isn't reliably available. +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + let mismatch = 0; + for (let i = 0; i < a.length; i++) { + mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return mismatch === 0; +} + /** * Assert that the provided token generates a matching signature. */ @@ -172,7 +186,7 @@ export function assertTokenSignature(token: string, key: string, signature?: str } const expectedSignature = createTokenSignature(token, key); - if (expectedSignature !== signature) { + if (!constantTimeEqual(expectedSignature, signature)) { throw new Error(authSignatureInvalid); } } From ea23f699423abcce277ea055ef3dd473edb57a1b Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 27 Apr 2026 10:55:11 -0500 Subject: [PATCH 2/2] docs(nextjs): Expand JSDoc on constantTimeEqual with security rationale --- packages/nextjs/src/server/utils.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index 8b932d6c943..e6504478823 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -163,9 +163,18 @@ function createTokenSignature(token: string, key: string): string { return HmacSHA1(token, key).toString(); } -// Constant-time string equality. The signature is HMAC-SHA1 hex (fixed 40 chars), -// so the early length check leaks no secret. Synchronous and runtime-agnostic so -// it works in Edge Runtime, where node:crypto.timingSafeEqual isn't reliably available. +/** + * Constant-time string equality. Used to compare HMAC signatures without leaking + * timing information about how many leading characters matched — `===` and `!==` + * on strings short-circuit on the first mismatching character, which would let an + * attacker reconstruct the expected signature byte-by-byte across many timed + * requests against the Next.js origin. + * + * Synchronous and runtime-agnostic so it works in Edge Runtime, where + * `node:crypto.timingSafeEqual` isn't reliably available. The early length check + * leaks length, but is safe here because the only caller compares HMAC-SHA1 hex + * digests of fixed length (40 chars). + */ function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) { return false;