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 number Diff line number Diff 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 number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
mockJwks,
mockJwt,
mockJwtPayload,
mockM2MJwtPayload,
signingJwks,
} from '../../fixtures';
import {
Expand Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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/bash
set -euo pipefail

# Map the relevant file and surrounding symbols first.
ast-grep outline packages/backend/src/tokens/request.ts --view expanded || true

echo
echo "---- relevant line ranges ----"
nl -ba packages/backend/src/tokens/request.ts | sed -n '560,700p'

echo
echo "---- 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/bash
set -euo pipefail

echo "---- packages/backend/src/tokens/request.ts (lines 600-660) ----"
sed -n '600,660p' packages/backend/src/tokens/request.ts

echo
echo "---- 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/bash
set -euo pipefail

echo "---- state derivation around hasActiveClient / client UAT / session token ----"
sed -n '440,625p' packages/backend/src/tokens/request.ts

echo
echo "---- machine helpers ----"
sed -n '1,220p' packages/backend/src/tokens/machine.ts

Repository: clerk/javascript

Length of output: 14777


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- handleMaybeHandshakeStatus definition ----"
sed -n '300,380p' packages/backend/src/tokens/request.ts

echo
echo "---- 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 number Diff line number Diff 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 Down Expand 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