From 0cdc52498132148652064ffdb534853246784ea3 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 20 Apr 2026 11:33:16 +0100 Subject: [PATCH 1/3] fix(backend): Clock skew of 0 should not fall back Because 0 is falsy the current code fell back to the default value when the clock skew was configured to 0. This changes the syntax to fall back on null-ish values which 0 is not. --- .changeset/wacky-dryers-hammer.md | 5 +++++ packages/backend/src/jwt/verifyJwt.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/wacky-dryers-hammer.md diff --git a/.changeset/wacky-dryers-hammer.md b/.changeset/wacky-dryers-hammer.md new file mode 100644 index 00000000000..6076445c4c1 --- /dev/null +++ b/.changeset/wacky-dryers-hammer.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +A clock skew of 0 will not fall back to the default value anymore. diff --git a/packages/backend/src/jwt/verifyJwt.ts b/packages/backend/src/jwt/verifyJwt.ts index 5b828bd8a18..2c47e1a458a 100644 --- a/packages/backend/src/jwt/verifyJwt.ts +++ b/packages/backend/src/jwt/verifyJwt.ts @@ -131,7 +131,7 @@ export async function verifyJwt( options: VerifyJwtOptions, ): Promise> { const { audience, authorizedParties, clockSkewInMs, key, headerType } = options; - const clockSkew = clockSkewInMs || DEFAULT_CLOCK_SKEW_IN_MS; + const clockSkew = clockSkewInMs ?? DEFAULT_CLOCK_SKEW_IN_MS; const { data: decoded, errors } = decodeJwt(token); if (errors) { From 9aa73e204c93e4ffde9a788e50b926a4a50854d4 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 20 Apr 2026 15:32:16 +0100 Subject: [PATCH 2/3] Add tests --- .../src/jwt/__tests__/verifyJwt.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts index 4fd4022a884..62225991e94 100644 --- a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts +++ b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts @@ -217,4 +217,28 @@ describe('verifyJwt(jwt, options)', () => { expect(error?.message).toContain('Invalid JWT type'); expect(error?.message).toContain('Expected "at+jwt, application/at+jwt"'); }); + + it('rejects an expired JWT when clockSkewInMs is explicitly 0', async () => { + vi.setSystemTime(new Date((mockJwtPayload.exp + 1) * 1000)); + const inputVerifyJwtOptions = { + key: mockJwks.keys[0], + issuer: mockJwtPayload.iss, + authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], + clockSkewInMs: 0, + }; + const { errors: [error] = [] } = await verifyJwt(mockJwt, inputVerifyJwtOptions); + expect(error).toBeDefined(); + expect(error?.message).toContain('JWT is expired'); + }); + + it('accepts a recently expired JWT within the default clock skew when clockSkewInMs is undefined', async () => { + vi.setSystemTime(new Date((mockJwtPayload.exp + 1) * 1000)); + const inputVerifyJwtOptions = { + key: mockJwks.keys[0], + issuer: mockJwtPayload.iss, + authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], + }; + const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions); + expect(data).toEqual(mockJwtPayload); + }); }); From b6571c6f435e1255f7c7a51f61dbacc0c4f2dad3 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 20 Apr 2026 18:29:03 +0100 Subject: [PATCH 3/3] Reject NaN and infinite --- .../src/jwt/__tests__/verifyJwt.test.ts | 28 +++++++++++++++++++ packages/backend/src/jwt/verifyJwt.ts | 3 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts index 62225991e94..d2f529251d8 100644 --- a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts +++ b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts @@ -241,4 +241,32 @@ describe('verifyJwt(jwt, options)', () => { const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions); expect(data).toEqual(mockJwtPayload); }); + + it('falls back to the default clock skew when clockSkewInMs is NaN', async () => { + vi.setSystemTime(new Date((mockJwtPayload.exp + 1) * 1000)); + const inputVerifyJwtOptions = { + key: mockJwks.keys[0], + issuer: mockJwtPayload.iss, + authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], + clockSkewInMs: Number.NaN, + }; + const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions); + expect(data).toEqual(mockJwtPayload); + + vi.setSystemTime(new Date((mockJwtPayload.exp + 60) * 1000)); + const { errors: [error] = [] } = await verifyJwt(mockJwt, inputVerifyJwtOptions); + expect(error?.message).toContain('JWT is expired'); + }); + + it('falls back to the default clock skew when clockSkewInMs is Infinity', async () => { + vi.setSystemTime(new Date((mockJwtPayload.exp + 3600) * 1000)); + const inputVerifyJwtOptions = { + key: mockJwks.keys[0], + issuer: mockJwtPayload.iss, + authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], + clockSkewInMs: Number.POSITIVE_INFINITY, + }; + const { errors: [error] = [] } = await verifyJwt(mockJwt, inputVerifyJwtOptions); + expect(error?.message).toContain('JWT is expired'); + }); }); diff --git a/packages/backend/src/jwt/verifyJwt.ts b/packages/backend/src/jwt/verifyJwt.ts index 2c47e1a458a..b96055126c4 100644 --- a/packages/backend/src/jwt/verifyJwt.ts +++ b/packages/backend/src/jwt/verifyJwt.ts @@ -131,7 +131,8 @@ export async function verifyJwt( options: VerifyJwtOptions, ): Promise> { const { audience, authorizedParties, clockSkewInMs, key, headerType } = options; - const clockSkew = clockSkewInMs ?? DEFAULT_CLOCK_SKEW_IN_MS; + const clockSkew = + typeof clockSkewInMs === 'number' && Number.isFinite(clockSkewInMs) ? clockSkewInMs : DEFAULT_CLOCK_SKEW_IN_MS; const { data: decoded, errors } = decodeJwt(token); if (errors) {