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/shiny-words-lay.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Reject machine tokens (M2M and OAuth JWTs) presented in the `__session` cookie. Previously such a token could pass session verification and produce a signed-in state with the machine identity as `userId`, defeating `if (userId)` authorization checks. The cookie path now mirrors the existing header-path guard and returns a signed-out state for these tokens.
27 changes: 27 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ import {
mockJwks,
mockJwt,
mockJwtPayload,
mockM2MJwtPayload,
signingJwks,
} from '../../fixtures';
import {
Expand DownExpand Up@@ -1279,6 +1280,32 @@ describe('tokens.authenticateRequest(options)', () => {
expect(requestState.toAuth()).toBeSignedInToAuth();
});

test('cookieToken: returns signed out when an M2M JWT is presented in the __session cookie (SDK-107)', async () => {
// A same-instance M2M JWT carries the instance issuer, so it survives the suffixed-cookie check.
const { data: m2mJwt } = await signJwt({ ...mockM2MJwtPayload, iss: mockJwtPayload.iss }, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
});

const requestState = await authenticateRequest(
mockRequestWithCookies(
{},
{
__clerk_db_jwt: 'deadbeef',
__client_uat: `${mockJwtPayload.iat - 10}`,
__session: m2mJwt!,
},
),
mockOptions(),
);

expect(requestState).toBeSignedOut({
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
expect(requestState.toAuth()).toBeSignedOutToAuth();
});

// todo(
// 'cookieToken: returns signed in when cookieToken.iat >= clientUat and expired token and ssrToken [10y.2n.1y]',
// assert => {
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,22 @@ describe('tokens.verify(token, options)', () => {
expect(errors).toBeDefined();
expect(errors?.[0].message).toContain('signature');
});

it('rejects a JWT tagged with the M2M category before key resolution (AISEC-91)', async () => {
// Non-machine `sub` isolates the category guard from the sub-based machine-JWT check;
// no jwks server is mocked, proving the guard fires before any network call.
const token = await createSignedM2MJwt({ ...mockM2MJwtPayload, sub: mockJwtPayload.sub });

const { data, errors } = await verifyToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});

expect(data).toBeUndefined();
expect(errors?.[0].reason).toBe('token-invalid');
expect(errors?.[0].message).toBe('Invalid session token category.');
});
});

describe('tokens.verifyMachineAuthToken(token, options)', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -631,6 +631,17 @@ export const authenticateRequest: AuthenticateRequest = (async (
return handleSessionTokenError(decodedErrors[0], 'cookie');
}

// Machine JWTs pass verifyToken() but must not be accepted as session tokens (mirrors header path).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isMachineJwt(authenticateContext.sessionTokenInCookie!)) {
return signedOut({
tokenType: TokenType.SessionToken,
authenticateContext,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
}
Comment on lines +634 to +643

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
# Map the relevant file and surrounding symbols first.
ast-grep outline packages/backend/src/tokens/request.ts --view expanded ||trueechoecho"---- relevant line ranges ----"
nl -ba packages/backend/src/tokens/request.ts | sed -n '560,700p'echoecho"---- search for handshake helpers / machine jwt checks ----"
rg -n "isMachineJwt|handleMaybeHandshakeStatus|TokenTypeMismatch|__client_uat|sessionTokenInCookie|signedOut" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 1189


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- packages/backend/src/tokens/request.ts (lines 600-660) ----"
sed -n '600,660p' packages/backend/src/tokens/request.ts
echoecho"---- surrounding references ----"
rg -n "handleMaybeHandshakeStatus|isMachineJwt|sessionTokenInCookie|__client_uat|TokenTypeMismatch" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4888


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- state derivation around hasActiveClient / client UAT / session token ----"
sed -n '440,625p' packages/backend/src/tokens/request.ts
echoecho"---- machine helpers ----"
sed -n '1,220p' packages/backend/src/tokens/machine.ts

Repository: clerk/javascript

Length of output: 14777


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- handleMaybeHandshakeStatus definition ----"
sed -n '300,380p' packages/backend/src/tokens/request.ts
echoecho"---- call sites of handleMaybeHandshakeStatus in request.ts ----"
rg -n "function handleMaybeHandshakeStatus|return handleMaybeHandshakeStatus|handleMaybeHandshakeStatus\(" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4982


Move the machine-JWT check before the cookie handshake branches. A machine JWT in __session without __client_uat hits handleMaybeHandshakeStatus first, so it never returns the intended TokenTypeMismatch signed-out state. Add a regression case for the no-__client_uat path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/backend/src/tokens/request.ts` around lines 634 - 643, Move the
isMachineJwt check in the cookie authentication flow before the
handleMaybeHandshakeStatus branches, so machine JWTs in __session return the
TokenTypeMismatch signedOut result even when __client_uat is absent. Add a
regression case covering a machine JWT without __client_uat and verify the
expected signed-out response.


if (decodeResult.payload.iat < authenticateContext.clientUat) {
return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SessionTokenIATBeforeClientUAT, '');
}
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
import {
API_KEY_PREFIX,
isJwtFormat,
JWT_CATEGORY_M2M_TOKEN,
M2M_SUBJECT_PREFIX,
M2M_TOKEN_PREFIX,
OAUTH_ACCESS_TOKEN_TYPES,
Expand DownExpand Up@@ -119,6 +120,20 @@ export async function verifyToken(
const { header } = decodedResult;
const { kid } = header;

// Reject machine JWTs (e.g. M2M) tagged with a non-session category but signed by the same
// instance key, regardless of transport. Reciprocal of the machine verifier's `cat` check.
if (header.cat === JWT_CATEGORY_M2M_TOKEN) {
return {
errors: [
new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalid,
message: 'Invalid session token category.',
}),
],
};
}

try {
let key: JsonWebKey;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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/shiny-words-lay.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Reject machine tokens (M2M and OAuth JWTs) presented in the `__session` cookie. Previously such a token could pass session verification and produce a signed-in state with the machine identity as `userId`, defeating `if (userId)` authorization checks. The cookie path now mirrors the existing header-path guard and returns a signed-out state for these tokens.
27 changes: 27 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ import {
mockJwks,
mockJwt,
mockJwtPayload,
mockM2MJwtPayload,
signingJwks,
} from '../../fixtures';
import {
Expand DownExpand Up@@ -1279,6 +1280,32 @@ describe('tokens.authenticateRequest(options)', () => {
expect(requestState.toAuth()).toBeSignedInToAuth();
});

test('cookieToken: returns signed out when an M2M JWT is presented in the __session cookie (SDK-107)', async () => {
// A same-instance M2M JWT carries the instance issuer, so it survives the suffixed-cookie check.
const { data: m2mJwt } = await signJwt({ ...mockM2MJwtPayload, iss: mockJwtPayload.iss }, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
});

const requestState = await authenticateRequest(
mockRequestWithCookies(
{},
{
__clerk_db_jwt: 'deadbeef',
__client_uat: `${mockJwtPayload.iat - 10}`,
__session: m2mJwt!,
},
),
mockOptions(),
);

expect(requestState).toBeSignedOut({
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
expect(requestState.toAuth()).toBeSignedOutToAuth();
});

// todo(
// 'cookieToken: returns signed in when cookieToken.iat >= clientUat and expired token and ssrToken [10y.2n.1y]',
// assert => {
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,22 @@ describe('tokens.verify(token, options)', () => {
expect(errors).toBeDefined();
expect(errors?.[0].message).toContain('signature');
});

it('rejects a JWT tagged with the M2M category before key resolution (AISEC-91)', async () => {
// Non-machine `sub` isolates the category guard from the sub-based machine-JWT check;
// no jwks server is mocked, proving the guard fires before any network call.
const token = await createSignedM2MJwt({ ...mockM2MJwtPayload, sub: mockJwtPayload.sub });

const { data, errors } = await verifyToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});

expect(data).toBeUndefined();
expect(errors?.[0].reason).toBe('token-invalid');
expect(errors?.[0].message).toBe('Invalid session token category.');
});
});

describe('tokens.verifyMachineAuthToken(token, options)', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -631,6 +631,17 @@ export const authenticateRequest: AuthenticateRequest = (async (
return handleSessionTokenError(decodedErrors[0], 'cookie');
}

// Machine JWTs pass verifyToken() but must not be accepted as session tokens (mirrors header path).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isMachineJwt(authenticateContext.sessionTokenInCookie!)) {
return signedOut({
tokenType: TokenType.SessionToken,
authenticateContext,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
}
Comment on lines +634 to +643

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
# Map the relevant file and surrounding symbols first.
ast-grep outline packages/backend/src/tokens/request.ts --view expanded ||trueechoecho"---- relevant line ranges ----"
nl -ba packages/backend/src/tokens/request.ts | sed -n '560,700p'echoecho"---- search for handshake helpers / machine jwt checks ----"
rg -n "isMachineJwt|handleMaybeHandshakeStatus|TokenTypeMismatch|__client_uat|sessionTokenInCookie|signedOut" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 1189


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- packages/backend/src/tokens/request.ts (lines 600-660) ----"
sed -n '600,660p' packages/backend/src/tokens/request.ts
echoecho"---- surrounding references ----"
rg -n "handleMaybeHandshakeStatus|isMachineJwt|sessionTokenInCookie|__client_uat|TokenTypeMismatch" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4888


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- state derivation around hasActiveClient / client UAT / session token ----"
sed -n '440,625p' packages/backend/src/tokens/request.ts
echoecho"---- machine helpers ----"
sed -n '1,220p' packages/backend/src/tokens/machine.ts

Repository: clerk/javascript

Length of output: 14777


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- handleMaybeHandshakeStatus definition ----"
sed -n '300,380p' packages/backend/src/tokens/request.ts
echoecho"---- call sites of handleMaybeHandshakeStatus in request.ts ----"
rg -n "function handleMaybeHandshakeStatus|return handleMaybeHandshakeStatus|handleMaybeHandshakeStatus\(" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4982


Move the machine-JWT check before the cookie handshake branches. A machine JWT in __session without __client_uat hits handleMaybeHandshakeStatus first, so it never returns the intended TokenTypeMismatch signed-out state. Add a regression case for the no-__client_uat path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/backend/src/tokens/request.ts` around lines 634 - 643, Move the
isMachineJwt check in the cookie authentication flow before the
handleMaybeHandshakeStatus branches, so machine JWTs in __session return the
TokenTypeMismatch signedOut result even when __client_uat is absent. Add a
regression case covering a machine JWT without __client_uat and verify the
expected signed-out response.


if (decodeResult.payload.iat < authenticateContext.clientUat) {
return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SessionTokenIATBeforeClientUAT, '');
}
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
import {
API_KEY_PREFIX,
isJwtFormat,
JWT_CATEGORY_M2M_TOKEN,
M2M_SUBJECT_PREFIX,
M2M_TOKEN_PREFIX,
OAUTH_ACCESS_TOKEN_TYPES,
Expand DownExpand Up@@ -119,6 +120,20 @@ export async function verifyToken(
const { header } = decodedResult;
const { kid } = header;

// Reject machine JWTs (e.g. M2M) tagged with a non-session category but signed by the same
// instance key, regardless of transport. Reciprocal of the machine verifier's `cat` check.
if (header.cat === JWT_CATEGORY_M2M_TOKEN) {
return {
errors: [
new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalid,
message: 'Invalid session token category.',
}),
],
};
}

try {
let key: JsonWebKey;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/shiny-words-lay.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Reject machine tokens (M2M and OAuth JWTs) presented in the `__session` cookie. Previously such a token could pass session verification and produce a signed-in state with the machine identity as `userId`, defeating `if (userId)` authorization checks. The cookie path now mirrors the existing header-path guard and returns a signed-out state for these tokens.
27 changes: 27 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ import {
mockJwks,
mockJwt,
mockJwtPayload,
mockM2MJwtPayload,
signingJwks,
} from '../../fixtures';
import {
Expand DownExpand Up@@ -1279,6 +1280,32 @@ describe('tokens.authenticateRequest(options)', () => {
expect(requestState.toAuth()).toBeSignedInToAuth();
});

test('cookieToken: returns signed out when an M2M JWT is presented in the __session cookie (SDK-107)', async () => {
// A same-instance M2M JWT carries the instance issuer, so it survives the suffixed-cookie check.
const { data: m2mJwt } = await signJwt({ ...mockM2MJwtPayload, iss: mockJwtPayload.iss }, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
});

const requestState = await authenticateRequest(
mockRequestWithCookies(
{},
{
__clerk_db_jwt: 'deadbeef',
__client_uat: `${mockJwtPayload.iat - 10}`,
__session: m2mJwt!,
},
),
mockOptions(),
);

expect(requestState).toBeSignedOut({
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
expect(requestState.toAuth()).toBeSignedOutToAuth();
});

// todo(
// 'cookieToken: returns signed in when cookieToken.iat >= clientUat and expired token and ssrToken [10y.2n.1y]',
// assert => {
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,22 @@ describe('tokens.verify(token, options)', () => {
expect(errors).toBeDefined();
expect(errors?.[0].message).toContain('signature');
});

it('rejects a JWT tagged with the M2M category before key resolution (AISEC-91)', async () => {
// Non-machine `sub` isolates the category guard from the sub-based machine-JWT check;
// no jwks server is mocked, proving the guard fires before any network call.
const token = await createSignedM2MJwt({ ...mockM2MJwtPayload, sub: mockJwtPayload.sub });

const { data, errors } = await verifyToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});

expect(data).toBeUndefined();
expect(errors?.[0].reason).toBe('token-invalid');
expect(errors?.[0].message).toBe('Invalid session token category.');
});
});

describe('tokens.verifyMachineAuthToken(token, options)', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -631,6 +631,17 @@ export const authenticateRequest: AuthenticateRequest = (async (
return handleSessionTokenError(decodedErrors[0], 'cookie');
}

// Machine JWTs pass verifyToken() but must not be accepted as session tokens (mirrors header path).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isMachineJwt(authenticateContext.sessionTokenInCookie!)) {
return signedOut({
tokenType: TokenType.SessionToken,
authenticateContext,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
}
Comment on lines +634 to +643

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
# Map the relevant file and surrounding symbols first.
ast-grep outline packages/backend/src/tokens/request.ts --view expanded ||trueechoecho"---- relevant line ranges ----"
nl -ba packages/backend/src/tokens/request.ts | sed -n '560,700p'echoecho"---- search for handshake helpers / machine jwt checks ----"
rg -n "isMachineJwt|handleMaybeHandshakeStatus|TokenTypeMismatch|__client_uat|sessionTokenInCookie|signedOut" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 1189


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- packages/backend/src/tokens/request.ts (lines 600-660) ----"
sed -n '600,660p' packages/backend/src/tokens/request.ts
echoecho"---- surrounding references ----"
rg -n "handleMaybeHandshakeStatus|isMachineJwt|sessionTokenInCookie|__client_uat|TokenTypeMismatch" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4888


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- state derivation around hasActiveClient / client UAT / session token ----"
sed -n '440,625p' packages/backend/src/tokens/request.ts
echoecho"---- machine helpers ----"
sed -n '1,220p' packages/backend/src/tokens/machine.ts

Repository: clerk/javascript

Length of output: 14777


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- handleMaybeHandshakeStatus definition ----"
sed -n '300,380p' packages/backend/src/tokens/request.ts
echoecho"---- call sites of handleMaybeHandshakeStatus in request.ts ----"
rg -n "function handleMaybeHandshakeStatus|return handleMaybeHandshakeStatus|handleMaybeHandshakeStatus\(" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4982


Move the machine-JWT check before the cookie handshake branches. A machine JWT in __session without __client_uat hits handleMaybeHandshakeStatus first, so it never returns the intended TokenTypeMismatch signed-out state. Add a regression case for the no-__client_uat path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/backend/src/tokens/request.ts` around lines 634 - 643, Move the
isMachineJwt check in the cookie authentication flow before the
handleMaybeHandshakeStatus branches, so machine JWTs in __session return the
TokenTypeMismatch signedOut result even when __client_uat is absent. Add a
regression case covering a machine JWT without __client_uat and verify the
expected signed-out response.


if (decodeResult.payload.iat < authenticateContext.clientUat) {
return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SessionTokenIATBeforeClientUAT, '');
}
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
import {
API_KEY_PREFIX,
isJwtFormat,
JWT_CATEGORY_M2M_TOKEN,
M2M_SUBJECT_PREFIX,
M2M_TOKEN_PREFIX,
OAUTH_ACCESS_TOKEN_TYPES,
Expand DownExpand Up@@ -119,6 +120,20 @@ export async function verifyToken(
const { header } = decodedResult;
const { kid } = header;

// Reject machine JWTs (e.g. M2M) tagged with a non-session category but signed by the same
// instance key, regardless of transport. Reciprocal of the machine verifier's `cat` check.
if (header.cat === JWT_CATEGORY_M2M_TOKEN) {
return {
errors: [
new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalid,
message: 'Invalid session token category.',
}),
],
};
}

try {
let key: JsonWebKey;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/shiny-words-lay.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Reject machine tokens (M2M and OAuth JWTs) presented in the `__session` cookie. Previously such a token could pass session verification and produce a signed-in state with the machine identity as `userId`, defeating `if (userId)` authorization checks. The cookie path now mirrors the existing header-path guard and returns a signed-out state for these tokens.
27 changes: 27 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ import {
mockJwks,
mockJwt,
mockJwtPayload,
mockM2MJwtPayload,
signingJwks,
} from '../../fixtures';
import {
Expand DownExpand Up@@ -1279,6 +1280,32 @@ describe('tokens.authenticateRequest(options)', () => {
expect(requestState.toAuth()).toBeSignedInToAuth();
});

test('cookieToken: returns signed out when an M2M JWT is presented in the __session cookie (SDK-107)', async () => {
// A same-instance M2M JWT carries the instance issuer, so it survives the suffixed-cookie check.
const { data: m2mJwt } = await signJwt({ ...mockM2MJwtPayload, iss: mockJwtPayload.iss }, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
});

const requestState = await authenticateRequest(
mockRequestWithCookies(
{},
{
__clerk_db_jwt: 'deadbeef',
__client_uat: `${mockJwtPayload.iat - 10}`,
__session: m2mJwt!,
},
),
mockOptions(),
);

expect(requestState).toBeSignedOut({
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
expect(requestState.toAuth()).toBeSignedOutToAuth();
});

// todo(
// 'cookieToken: returns signed in when cookieToken.iat >= clientUat and expired token and ssrToken [10y.2n.1y]',
// assert => {
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,22 @@ describe('tokens.verify(token, options)', () => {
expect(errors).toBeDefined();
expect(errors?.[0].message).toContain('signature');
});

it('rejects a JWT tagged with the M2M category before key resolution (AISEC-91)', async () => {
// Non-machine `sub` isolates the category guard from the sub-based machine-JWT check;
// no jwks server is mocked, proving the guard fires before any network call.
const token = await createSignedM2MJwt({ ...mockM2MJwtPayload, sub: mockJwtPayload.sub });

const { data, errors } = await verifyToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});

expect(data).toBeUndefined();
expect(errors?.[0].reason).toBe('token-invalid');
expect(errors?.[0].message).toBe('Invalid session token category.');
});
});

describe('tokens.verifyMachineAuthToken(token, options)', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -631,6 +631,17 @@ export const authenticateRequest: AuthenticateRequest = (async (
return handleSessionTokenError(decodedErrors[0], 'cookie');
}

// Machine JWTs pass verifyToken() but must not be accepted as session tokens (mirrors header path).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isMachineJwt(authenticateContext.sessionTokenInCookie!)) {
return signedOut({
tokenType: TokenType.SessionToken,
authenticateContext,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
}
Comment on lines +634 to +643

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
# Map the relevant file and surrounding symbols first.
ast-grep outline packages/backend/src/tokens/request.ts --view expanded ||trueechoecho"---- relevant line ranges ----"
nl -ba packages/backend/src/tokens/request.ts | sed -n '560,700p'echoecho"---- search for handshake helpers / machine jwt checks ----"
rg -n "isMachineJwt|handleMaybeHandshakeStatus|TokenTypeMismatch|__client_uat|sessionTokenInCookie|signedOut" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 1189


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- packages/backend/src/tokens/request.ts (lines 600-660) ----"
sed -n '600,660p' packages/backend/src/tokens/request.ts
echoecho"---- surrounding references ----"
rg -n "handleMaybeHandshakeStatus|isMachineJwt|sessionTokenInCookie|__client_uat|TokenTypeMismatch" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4888


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- state derivation around hasActiveClient / client UAT / session token ----"
sed -n '440,625p' packages/backend/src/tokens/request.ts
echoecho"---- machine helpers ----"
sed -n '1,220p' packages/backend/src/tokens/machine.ts

Repository: clerk/javascript

Length of output: 14777


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- handleMaybeHandshakeStatus definition ----"
sed -n '300,380p' packages/backend/src/tokens/request.ts
echoecho"---- call sites of handleMaybeHandshakeStatus in request.ts ----"
rg -n "function handleMaybeHandshakeStatus|return handleMaybeHandshakeStatus|handleMaybeHandshakeStatus\(" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4982


Move the machine-JWT check before the cookie handshake branches. A machine JWT in __session without __client_uat hits handleMaybeHandshakeStatus first, so it never returns the intended TokenTypeMismatch signed-out state. Add a regression case for the no-__client_uat path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/backend/src/tokens/request.ts` around lines 634 - 643, Move the
isMachineJwt check in the cookie authentication flow before the
handleMaybeHandshakeStatus branches, so machine JWTs in __session return the
TokenTypeMismatch signedOut result even when __client_uat is absent. Add a
regression case covering a machine JWT without __client_uat and verify the
expected signed-out response.


if (decodeResult.payload.iat < authenticateContext.clientUat) {
return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SessionTokenIATBeforeClientUAT, '');
}
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
import {
API_KEY_PREFIX,
isJwtFormat,
JWT_CATEGORY_M2M_TOKEN,
M2M_SUBJECT_PREFIX,
M2M_TOKEN_PREFIX,
OAUTH_ACCESS_TOKEN_TYPES,
Expand DownExpand Up@@ -119,6 +120,20 @@ export async function verifyToken(
const { header } = decodedResult;
const { kid } = header;

// Reject machine JWTs (e.g. M2M) tagged with a non-session category but signed by the same
// instance key, regardless of transport. Reciprocal of the machine verifier's `cat` check.
if (header.cat === JWT_CATEGORY_M2M_TOKEN) {
return {
errors: [
new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalid,
message: 'Invalid session token category.',
}),
],
};
}

try {
let key: JsonWebKey;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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/shiny-words-lay.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Reject machine tokens (M2M and OAuth JWTs) presented in the `__session` cookie. Previously such a token could pass session verification and produce a signed-in state with the machine identity as `userId`, defeating `if (userId)` authorization checks. The cookie path now mirrors the existing header-path guard and returns a signed-out state for these tokens.
27 changes: 27 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ import {
mockJwks,
mockJwt,
mockJwtPayload,
mockM2MJwtPayload,
signingJwks,
} from '../../fixtures';
import {
Expand DownExpand Up@@ -1279,6 +1280,32 @@ describe('tokens.authenticateRequest(options)', () => {
expect(requestState.toAuth()).toBeSignedInToAuth();
});

test('cookieToken: returns signed out when an M2M JWT is presented in the __session cookie (SDK-107)', async () => {
// A same-instance M2M JWT carries the instance issuer, so it survives the suffixed-cookie check.
const { data: m2mJwt } = await signJwt({ ...mockM2MJwtPayload, iss: mockJwtPayload.iss }, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
});

const requestState = await authenticateRequest(
mockRequestWithCookies(
{},
{
__clerk_db_jwt: 'deadbeef',
__client_uat: `${mockJwtPayload.iat - 10}`,
__session: m2mJwt!,
},
),
mockOptions(),
);

expect(requestState).toBeSignedOut({
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
expect(requestState.toAuth()).toBeSignedOutToAuth();
});

// todo(
// 'cookieToken: returns signed in when cookieToken.iat >= clientUat and expired token and ssrToken [10y.2n.1y]',
// assert => {
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,22 @@ describe('tokens.verify(token, options)', () => {
expect(errors).toBeDefined();
expect(errors?.[0].message).toContain('signature');
});

it('rejects a JWT tagged with the M2M category before key resolution (AISEC-91)', async () => {
// Non-machine `sub` isolates the category guard from the sub-based machine-JWT check;
// no jwks server is mocked, proving the guard fires before any network call.
const token = await createSignedM2MJwt({ ...mockM2MJwtPayload, sub: mockJwtPayload.sub });

const { data, errors } = await verifyToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});

expect(data).toBeUndefined();
expect(errors?.[0].reason).toBe('token-invalid');
expect(errors?.[0].message).toBe('Invalid session token category.');
});
});

describe('tokens.verifyMachineAuthToken(token, options)', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -631,6 +631,17 @@ export const authenticateRequest: AuthenticateRequest = (async (
return handleSessionTokenError(decodedErrors[0], 'cookie');
}

// Machine JWTs pass verifyToken() but must not be accepted as session tokens (mirrors header path).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isMachineJwt(authenticateContext.sessionTokenInCookie!)) {
return signedOut({
tokenType: TokenType.SessionToken,
authenticateContext,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
}
Comment on lines +634 to +643

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
# Map the relevant file and surrounding symbols first.
ast-grep outline packages/backend/src/tokens/request.ts --view expanded ||trueechoecho"---- relevant line ranges ----"
nl -ba packages/backend/src/tokens/request.ts | sed -n '560,700p'echoecho"---- search for handshake helpers / machine jwt checks ----"
rg -n "isMachineJwt|handleMaybeHandshakeStatus|TokenTypeMismatch|__client_uat|sessionTokenInCookie|signedOut" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 1189


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- packages/backend/src/tokens/request.ts (lines 600-660) ----"
sed -n '600,660p' packages/backend/src/tokens/request.ts
echoecho"---- surrounding references ----"
rg -n "handleMaybeHandshakeStatus|isMachineJwt|sessionTokenInCookie|__client_uat|TokenTypeMismatch" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4888


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- state derivation around hasActiveClient / client UAT / session token ----"
sed -n '440,625p' packages/backend/src/tokens/request.ts
echoecho"---- machine helpers ----"
sed -n '1,220p' packages/backend/src/tokens/machine.ts

Repository: clerk/javascript

Length of output: 14777


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- handleMaybeHandshakeStatus definition ----"
sed -n '300,380p' packages/backend/src/tokens/request.ts
echoecho"---- call sites of handleMaybeHandshakeStatus in request.ts ----"
rg -n "function handleMaybeHandshakeStatus|return handleMaybeHandshakeStatus|handleMaybeHandshakeStatus\(" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4982


Move the machine-JWT check before the cookie handshake branches. A machine JWT in __session without __client_uat hits handleMaybeHandshakeStatus first, so it never returns the intended TokenTypeMismatch signed-out state. Add a regression case for the no-__client_uat path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/backend/src/tokens/request.ts` around lines 634 - 643, Move the
isMachineJwt check in the cookie authentication flow before the
handleMaybeHandshakeStatus branches, so machine JWTs in __session return the
TokenTypeMismatch signedOut result even when __client_uat is absent. Add a
regression case covering a machine JWT without __client_uat and verify the
expected signed-out response.


if (decodeResult.payload.iat < authenticateContext.clientUat) {
return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SessionTokenIATBeforeClientUAT, '');
}
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
import {
API_KEY_PREFIX,
isJwtFormat,
JWT_CATEGORY_M2M_TOKEN,
M2M_SUBJECT_PREFIX,
M2M_TOKEN_PREFIX,
OAUTH_ACCESS_TOKEN_TYPES,
Expand DownExpand Up@@ -119,6 +120,20 @@ export async function verifyToken(
const { header } = decodedResult;
const { kid } = header;

// Reject machine JWTs (e.g. M2M) tagged with a non-session category but signed by the same
// instance key, regardless of transport. Reciprocal of the machine verifier's `cat` check.
if (header.cat === JWT_CATEGORY_M2M_TOKEN) {
return {
errors: [
new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalid,
message: 'Invalid session token category.',
}),
],
};
}

try {
let key: JsonWebKey;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/shiny-words-lay.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Reject machine tokens (M2M and OAuth JWTs) presented in the `__session` cookie. Previously such a token could pass session verification and produce a signed-in state with the machine identity as `userId`, defeating `if (userId)` authorization checks. The cookie path now mirrors the existing header-path guard and returns a signed-out state for these tokens.
27 changes: 27 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ import {
mockJwks,
mockJwt,
mockJwtPayload,
mockM2MJwtPayload,
signingJwks,
} from '../../fixtures';
import {
Expand DownExpand Up@@ -1279,6 +1280,32 @@ describe('tokens.authenticateRequest(options)', () => {
expect(requestState.toAuth()).toBeSignedInToAuth();
});

test('cookieToken: returns signed out when an M2M JWT is presented in the __session cookie (SDK-107)', async () => {
// A same-instance M2M JWT carries the instance issuer, so it survives the suffixed-cookie check.
const { data: m2mJwt } = await signJwt({ ...mockM2MJwtPayload, iss: mockJwtPayload.iss }, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
});

const requestState = await authenticateRequest(
mockRequestWithCookies(
{},
{
__clerk_db_jwt: 'deadbeef',
__client_uat: `${mockJwtPayload.iat - 10}`,
__session: m2mJwt!,
},
),
mockOptions(),
);

expect(requestState).toBeSignedOut({
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
expect(requestState.toAuth()).toBeSignedOutToAuth();
});

// todo(
// 'cookieToken: returns signed in when cookieToken.iat >= clientUat and expired token and ssrToken [10y.2n.1y]',
// assert => {
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,22 @@ describe('tokens.verify(token, options)', () => {
expect(errors).toBeDefined();
expect(errors?.[0].message).toContain('signature');
});

it('rejects a JWT tagged with the M2M category before key resolution (AISEC-91)', async () => {
// Non-machine `sub` isolates the category guard from the sub-based machine-JWT check;
// no jwks server is mocked, proving the guard fires before any network call.
const token = await createSignedM2MJwt({ ...mockM2MJwtPayload, sub: mockJwtPayload.sub });

const { data, errors } = await verifyToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});

expect(data).toBeUndefined();
expect(errors?.[0].reason).toBe('token-invalid');
expect(errors?.[0].message).toBe('Invalid session token category.');
});
});

describe('tokens.verifyMachineAuthToken(token, options)', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -631,6 +631,17 @@ export const authenticateRequest: AuthenticateRequest = (async (
return handleSessionTokenError(decodedErrors[0], 'cookie');
}

// Machine JWTs pass verifyToken() but must not be accepted as session tokens (mirrors header path).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isMachineJwt(authenticateContext.sessionTokenInCookie!)) {
return signedOut({
tokenType: TokenType.SessionToken,
authenticateContext,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
}
Comment on lines +634 to +643

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
# Map the relevant file and surrounding symbols first.
ast-grep outline packages/backend/src/tokens/request.ts --view expanded ||trueechoecho"---- relevant line ranges ----"
nl -ba packages/backend/src/tokens/request.ts | sed -n '560,700p'echoecho"---- search for handshake helpers / machine jwt checks ----"
rg -n "isMachineJwt|handleMaybeHandshakeStatus|TokenTypeMismatch|__client_uat|sessionTokenInCookie|signedOut" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 1189


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- packages/backend/src/tokens/request.ts (lines 600-660) ----"
sed -n '600,660p' packages/backend/src/tokens/request.ts
echoecho"---- surrounding references ----"
rg -n "handleMaybeHandshakeStatus|isMachineJwt|sessionTokenInCookie|__client_uat|TokenTypeMismatch" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4888


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- state derivation around hasActiveClient / client UAT / session token ----"
sed -n '440,625p' packages/backend/src/tokens/request.ts
echoecho"---- machine helpers ----"
sed -n '1,220p' packages/backend/src/tokens/machine.ts

Repository: clerk/javascript

Length of output: 14777


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- handleMaybeHandshakeStatus definition ----"
sed -n '300,380p' packages/backend/src/tokens/request.ts
echoecho"---- call sites of handleMaybeHandshakeStatus in request.ts ----"
rg -n "function handleMaybeHandshakeStatus|return handleMaybeHandshakeStatus|handleMaybeHandshakeStatus\(" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4982


Move the machine-JWT check before the cookie handshake branches. A machine JWT in __session without __client_uat hits handleMaybeHandshakeStatus first, so it never returns the intended TokenTypeMismatch signed-out state. Add a regression case for the no-__client_uat path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/backend/src/tokens/request.ts` around lines 634 - 643, Move the
isMachineJwt check in the cookie authentication flow before the
handleMaybeHandshakeStatus branches, so machine JWTs in __session return the
TokenTypeMismatch signedOut result even when __client_uat is absent. Add a
regression case covering a machine JWT without __client_uat and verify the
expected signed-out response.


if (decodeResult.payload.iat < authenticateContext.clientUat) {
return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SessionTokenIATBeforeClientUAT, '');
}
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
import {
API_KEY_PREFIX,
isJwtFormat,
JWT_CATEGORY_M2M_TOKEN,
M2M_SUBJECT_PREFIX,
M2M_TOKEN_PREFIX,
OAUTH_ACCESS_TOKEN_TYPES,
Expand DownExpand Up@@ -119,6 +120,20 @@ export async function verifyToken(
const { header } = decodedResult;
const { kid } = header;

// Reject machine JWTs (e.g. M2M) tagged with a non-session category but signed by the same
// instance key, regardless of transport. Reciprocal of the machine verifier's `cat` check.
if (header.cat === JWT_CATEGORY_M2M_TOKEN) {
return {
errors: [
new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalid,
message: 'Invalid session token category.',
}),
],
};
}

try {
let key: JsonWebKey;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/shiny-words-lay.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Reject machine tokens (M2M and OAuth JWTs) presented in the `__session` cookie. Previously such a token could pass session verification and produce a signed-in state with the machine identity as `userId`, defeating `if (userId)` authorization checks. The cookie path now mirrors the existing header-path guard and returns a signed-out state for these tokens.
27 changes: 27 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ import {
mockJwks,
mockJwt,
mockJwtPayload,
mockM2MJwtPayload,
signingJwks,
} from '../../fixtures';
import {
Expand DownExpand Up@@ -1279,6 +1280,32 @@ describe('tokens.authenticateRequest(options)', () => {
expect(requestState.toAuth()).toBeSignedInToAuth();
});

test('cookieToken: returns signed out when an M2M JWT is presented in the __session cookie (SDK-107)', async () => {
// A same-instance M2M JWT carries the instance issuer, so it survives the suffixed-cookie check.
const { data: m2mJwt } = await signJwt({ ...mockM2MJwtPayload, iss: mockJwtPayload.iss }, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
});

const requestState = await authenticateRequest(
mockRequestWithCookies(
{},
{
__clerk_db_jwt: 'deadbeef',
__client_uat: `${mockJwtPayload.iat - 10}`,
__session: m2mJwt!,
},
),
mockOptions(),
);

expect(requestState).toBeSignedOut({
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
expect(requestState.toAuth()).toBeSignedOutToAuth();
});

// todo(
// 'cookieToken: returns signed in when cookieToken.iat >= clientUat and expired token and ssrToken [10y.2n.1y]',
// assert => {
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,22 @@ describe('tokens.verify(token, options)', () => {
expect(errors).toBeDefined();
expect(errors?.[0].message).toContain('signature');
});

it('rejects a JWT tagged with the M2M category before key resolution (AISEC-91)', async () => {
// Non-machine `sub` isolates the category guard from the sub-based machine-JWT check;
// no jwks server is mocked, proving the guard fires before any network call.
const token = await createSignedM2MJwt({ ...mockM2MJwtPayload, sub: mockJwtPayload.sub });

const { data, errors } = await verifyToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});

expect(data).toBeUndefined();
expect(errors?.[0].reason).toBe('token-invalid');
expect(errors?.[0].message).toBe('Invalid session token category.');
});
});

describe('tokens.verifyMachineAuthToken(token, options)', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -631,6 +631,17 @@ export const authenticateRequest: AuthenticateRequest = (async (
return handleSessionTokenError(decodedErrors[0], 'cookie');
}

// Machine JWTs pass verifyToken() but must not be accepted as session tokens (mirrors header path).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isMachineJwt(authenticateContext.sessionTokenInCookie!)) {
return signedOut({
tokenType: TokenType.SessionToken,
authenticateContext,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
}
Comment on lines +634 to +643

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
# Map the relevant file and surrounding symbols first.
ast-grep outline packages/backend/src/tokens/request.ts --view expanded ||trueechoecho"---- relevant line ranges ----"
nl -ba packages/backend/src/tokens/request.ts | sed -n '560,700p'echoecho"---- search for handshake helpers / machine jwt checks ----"
rg -n "isMachineJwt|handleMaybeHandshakeStatus|TokenTypeMismatch|__client_uat|sessionTokenInCookie|signedOut" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 1189


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- packages/backend/src/tokens/request.ts (lines 600-660) ----"
sed -n '600,660p' packages/backend/src/tokens/request.ts
echoecho"---- surrounding references ----"
rg -n "handleMaybeHandshakeStatus|isMachineJwt|sessionTokenInCookie|__client_uat|TokenTypeMismatch" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4888


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- state derivation around hasActiveClient / client UAT / session token ----"
sed -n '440,625p' packages/backend/src/tokens/request.ts
echoecho"---- machine helpers ----"
sed -n '1,220p' packages/backend/src/tokens/machine.ts

Repository: clerk/javascript

Length of output: 14777


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- handleMaybeHandshakeStatus definition ----"
sed -n '300,380p' packages/backend/src/tokens/request.ts
echoecho"---- call sites of handleMaybeHandshakeStatus in request.ts ----"
rg -n "function handleMaybeHandshakeStatus|return handleMaybeHandshakeStatus|handleMaybeHandshakeStatus\(" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4982


Move the machine-JWT check before the cookie handshake branches. A machine JWT in __session without __client_uat hits handleMaybeHandshakeStatus first, so it never returns the intended TokenTypeMismatch signed-out state. Add a regression case for the no-__client_uat path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/backend/src/tokens/request.ts` around lines 634 - 643, Move the
isMachineJwt check in the cookie authentication flow before the
handleMaybeHandshakeStatus branches, so machine JWTs in __session return the
TokenTypeMismatch signedOut result even when __client_uat is absent. Add a
regression case covering a machine JWT without __client_uat and verify the
expected signed-out response.


if (decodeResult.payload.iat < authenticateContext.clientUat) {
return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SessionTokenIATBeforeClientUAT, '');
}
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
import {
API_KEY_PREFIX,
isJwtFormat,
JWT_CATEGORY_M2M_TOKEN,
M2M_SUBJECT_PREFIX,
M2M_TOKEN_PREFIX,
OAUTH_ACCESS_TOKEN_TYPES,
Expand DownExpand Up@@ -119,6 +120,20 @@ export async function verifyToken(
const { header } = decodedResult;
const { kid } = header;

// Reject machine JWTs (e.g. M2M) tagged with a non-session category but signed by the same
// instance key, regardless of transport. Reciprocal of the machine verifier's `cat` check.
if (header.cat === JWT_CATEGORY_M2M_TOKEN) {
return {
errors: [
new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalid,
message: 'Invalid session token category.',
}),
],
};
}

try {
let key: JsonWebKey;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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/shiny-words-lay.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Reject machine tokens (M2M and OAuth JWTs) presented in the `__session` cookie. Previously such a token could pass session verification and produce a signed-in state with the machine identity as `userId`, defeating `if (userId)` authorization checks. The cookie path now mirrors the existing header-path guard and returns a signed-out state for these tokens.
27 changes: 27 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ import {
mockJwks,
mockJwt,
mockJwtPayload,
mockM2MJwtPayload,
signingJwks,
} from '../../fixtures';
import {
Expand DownExpand Up@@ -1279,6 +1280,32 @@ describe('tokens.authenticateRequest(options)', () => {
expect(requestState.toAuth()).toBeSignedInToAuth();
});

test('cookieToken: returns signed out when an M2M JWT is presented in the __session cookie (SDK-107)', async () => {
// A same-instance M2M JWT carries the instance issuer, so it survives the suffixed-cookie check.
const { data: m2mJwt } = await signJwt({ ...mockM2MJwtPayload, iss: mockJwtPayload.iss }, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
});

const requestState = await authenticateRequest(
mockRequestWithCookies(
{},
{
__clerk_db_jwt: 'deadbeef',
__client_uat: `${mockJwtPayload.iat - 10}`,
__session: m2mJwt!,
},
),
mockOptions(),
);

expect(requestState).toBeSignedOut({
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
expect(requestState.toAuth()).toBeSignedOutToAuth();
});

// todo(
// 'cookieToken: returns signed in when cookieToken.iat >= clientUat and expired token and ssrToken [10y.2n.1y]',
// assert => {
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,22 @@ describe('tokens.verify(token, options)', () => {
expect(errors).toBeDefined();
expect(errors?.[0].message).toContain('signature');
});

it('rejects a JWT tagged with the M2M category before key resolution (AISEC-91)', async () => {
// Non-machine `sub` isolates the category guard from the sub-based machine-JWT check;
// no jwks server is mocked, proving the guard fires before any network call.
const token = await createSignedM2MJwt({ ...mockM2MJwtPayload, sub: mockJwtPayload.sub });

const { data, errors } = await verifyToken(token, {
apiUrl: 'https://api.clerk.test',
secretKey: 'a-valid-key',
skipJwksCache: true,
});

expect(data).toBeUndefined();
expect(errors?.[0].reason).toBe('token-invalid');
expect(errors?.[0].message).toBe('Invalid session token category.');
});
});

describe('tokens.verifyMachineAuthToken(token, options)', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -631,6 +631,17 @@ export const authenticateRequest: AuthenticateRequest = (async (
return handleSessionTokenError(decodedErrors[0], 'cookie');
}

// Machine JWTs pass verifyToken() but must not be accepted as session tokens (mirrors header path).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isMachineJwt(authenticateContext.sessionTokenInCookie!)) {
return signedOut({
tokenType: TokenType.SessionToken,
authenticateContext,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
}
Comment on lines +634 to +643

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
# Map the relevant file and surrounding symbols first.
ast-grep outline packages/backend/src/tokens/request.ts --view expanded ||trueechoecho"---- relevant line ranges ----"
nl -ba packages/backend/src/tokens/request.ts | sed -n '560,700p'echoecho"---- search for handshake helpers / machine jwt checks ----"
rg -n "isMachineJwt|handleMaybeHandshakeStatus|TokenTypeMismatch|__client_uat|sessionTokenInCookie|signedOut" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 1189


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- packages/backend/src/tokens/request.ts (lines 600-660) ----"
sed -n '600,660p' packages/backend/src/tokens/request.ts
echoecho"---- surrounding references ----"
rg -n "handleMaybeHandshakeStatus|isMachineJwt|sessionTokenInCookie|__client_uat|TokenTypeMismatch" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4888


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- state derivation around hasActiveClient / client UAT / session token ----"
sed -n '440,625p' packages/backend/src/tokens/request.ts
echoecho"---- machine helpers ----"
sed -n '1,220p' packages/backend/src/tokens/machine.ts

Repository: clerk/javascript

Length of output: 14777


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"---- handleMaybeHandshakeStatus definition ----"
sed -n '300,380p' packages/backend/src/tokens/request.ts
echoecho"---- call sites of handleMaybeHandshakeStatus in request.ts ----"
rg -n "function handleMaybeHandshakeStatus|return handleMaybeHandshakeStatus|handleMaybeHandshakeStatus\(" packages/backend/src/tokens/request.ts

Repository: clerk/javascript

Length of output: 4982


Move the machine-JWT check before the cookie handshake branches. A machine JWT in __session without __client_uat hits handleMaybeHandshakeStatus first, so it never returns the intended TokenTypeMismatch signed-out state. Add a regression case for the no-__client_uat path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/backend/src/tokens/request.ts` around lines 634 - 643, Move the
isMachineJwt check in the cookie authentication flow before the
handleMaybeHandshakeStatus branches, so machine JWTs in __session return the
TokenTypeMismatch signedOut result even when __client_uat is absent. Add a
regression case covering a machine JWT without __client_uat and verify the
expected signed-out response.


if (decodeResult.payload.iat < authenticateContext.clientUat) {
return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SessionTokenIATBeforeClientUAT, '');
}
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/src/tokens/verify.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
import {
API_KEY_PREFIX,
isJwtFormat,
JWT_CATEGORY_M2M_TOKEN,
M2M_SUBJECT_PREFIX,
M2M_TOKEN_PREFIX,
OAUTH_ACCESS_TOKEN_TYPES,
Expand DownExpand Up@@ -119,6 +120,20 @@ export async function verifyToken(
const { header } = decodedResult;
const { kid } = header;

// Reject machine JWTs (e.g. M2M) tagged with a non-session category but signed by the same
// instance key, regardless of transport. Reciprocal of the machine verifier's `cat` check.
if (header.cat === JWT_CATEGORY_M2M_TOKEN) {
return {
errors: [
new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalid,
message: 'Invalid session token category.',
}),
],
};
}

try {
let key: JsonWebKey;

Expand Down
Loading