Skip to content

chore(backend,nextjs): Improve token handling and optimize verification #6123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jun 16, 2025
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
8 changes: 8 additions & 0 deletions .changeset/twenty-beds-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/backend': minor
'@clerk/nextjs': minor
---

- Optimize `auth()` calls to avoid unnecessary verification calls when the provided token type is not in the `acceptsToken` array.
- Add handling for invalid token types when `acceptsToken` is an array in `authenticateRequest()`: now returns a clear unauthenticated state (`tokenType: null`) if the token is not in the accepted list.

2 changes: 1 addition & 1 deletion packages/backend/src/tokens/__tests__/request.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ test('returns the correct `authenticateRequest()` return type for each accepted
// Array of token types
expectTypeOf(
authenticateRequest(request, { acceptsToken: ['session_token', 'api_key', 'machine_token'] }),
).toMatchTypeOf<Promise<RequestState<'session_token' | 'api_key' | 'machine_token'>>>();
).toMatchTypeOf<Promise<RequestState<'session_token' | 'api_key' | 'machine_token' | null>>>();

// Any token type
expectTypeOf(authenticateRequest(request, { acceptsToken: 'any' })).toMatchTypeOf<Promise<RequestState<TokenType>>>();
Expand Down
26 changes: 15 additions & 11 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { AuthReason } from '../authStatus';
import { AuthErrorReason, AuthStatus } from '../authStatus';
import { OrganizationMatcher } from '../organizationMatcher';
import { authenticateRequest, RefreshTokenErrorReason } from '../request';
import type { MachineTokenType } from '../tokenTypes';
import { type MachineTokenType, TokenType } from '../tokenTypes';
import type { AuthenticateRequestOptions } from '../types';

const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA';
Expand Down Expand Up @@ -236,7 +236,7 @@ expect.extend({
toBeMachineUnauthenticated(
received,
expected: {
tokenType: MachineTokenType;
tokenType: MachineTokenType | null;
reason: AuthReason;
message: string;
},
Expand All @@ -246,6 +246,7 @@ expect.extend({
received.tokenType === expected.tokenType &&
received.reason === expected.reason &&
received.message === expected.message &&
!received.isAuthenticated &&
!received.token;

if (pass) {
Expand All @@ -264,15 +265,11 @@ expect.extend({
toBeMachineUnauthenticatedToAuth(
received,
expected: {
tokenType: MachineTokenType;
tokenType: MachineTokenType | null;
},
) {
const pass =
received.tokenType === expected.tokenType &&
!received.claims &&
!received.subject &&
!received.name &&
!received.id;
received.tokenType === expected.tokenType && !received.isAuthenticated && !received.name && !received.id;

if (pass) {
return {
Expand Down Expand Up @@ -1203,7 +1200,7 @@ describe('tokens.authenticateRequest(options)', () => {
});

// Test each token type with parameterized tests
const tokenTypes = ['api_key', 'oauth_token', 'machine_token'] as const;
const tokenTypes = [TokenType.ApiKey, TokenType.OAuthToken, TokenType.MachineToken];

describe.each(tokenTypes)('%s Authentication', tokenType => {
const mockToken = mockTokens[tokenType];
Expand Down Expand Up @@ -1240,6 +1237,7 @@ describe('tokens.authenticateRequest(options)', () => {
});
expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType,
isAuthenticated: false,
});
});
});
Expand Down Expand Up @@ -1289,6 +1287,7 @@ describe('tokens.authenticateRequest(options)', () => {
});
expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType: 'api_key',
isAuthenticated: false,
});
});

Expand All @@ -1303,6 +1302,7 @@ describe('tokens.authenticateRequest(options)', () => {
});
expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType: 'oauth_token',
isAuthenticated: false,
});
});

Expand All @@ -1317,6 +1317,7 @@ describe('tokens.authenticateRequest(options)', () => {
});
expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType: 'machine_token',
isAuthenticated: false,
});
});

Expand All @@ -1328,9 +1329,11 @@ describe('tokens.authenticateRequest(options)', () => {
tokenType: 'machine_token',
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
isAuthenticated: false,
});
expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType: 'machine_token',
isAuthenticated: false,
});
});
});
Expand Down Expand Up @@ -1360,12 +1363,13 @@ describe('tokens.authenticateRequest(options)', () => {
);

expect(requestState).toBeMachineUnauthenticated({
tokenType: 'machine_token',
tokenType: null,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType: 'machine_token',
tokenType: null,
isAuthenticated: false,
});
});
});
Expand Down
15 changes: 9 additions & 6 deletions packages/backend/src/tokens/authObjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,37 +428,40 @@ export const getAuthObjectFromJwt = (
* Returns an auth object matching the requested token type(s).
*
* If the parsed token type does not match any in acceptsToken, returns:
* - an unauthenticated machine object for the first machine token type in acceptsToken (if present), or
* - an invalid token auth object if the token is not in the accepted array
* - an unauthenticated machine object for machine tokens, or
* - a signed-out session object otherwise.
*
* This ensures the returned object always matches the developer's intent.
*/
export function getAuthObjectForAcceptedToken({
export const getAuthObjectForAcceptedToken = ({
authObject,
acceptsToken = TokenType.SessionToken,
}: {
authObject: AuthObject;
acceptsToken: AuthenticateRequestOptions['acceptsToken'];
}): AuthObject {
}): AuthObject => {
// 1. any token: return as-is
if (acceptsToken === 'any') {
return authObject;
}

// 2. array of tokens: must match one of the accepted types
if (Array.isArray(acceptsToken)) {
if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) {
// If the token is not in the accepted array, return invalid token auth object
return invalidTokenAuthObject();
}
return authObject;
}

// Single value: Intent based
// 3. single token: must match exactly, else return appropriate unauthenticated object
if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) {
if (isMachineTokenType(acceptsToken)) {
return unauthenticatedMachineObject(acceptsToken, authObject.debug);
}
return signedOutAuthObject(authObject.debug);
}

// 4. default: return as-is
return authObject;
}
};
47 changes: 37 additions & 10 deletions packages/backend/src/tokens/authStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import type { TokenVerificationErrorReason } from '../errors';
import type { AuthenticateContext } from './authenticateContext';
import type {
AuthenticatedMachineObject,
InvalidTokenAuthObject,
SignedInAuthObject,
SignedOutAuthObject,
UnauthenticatedMachineObject,
} from './authObjects';
import {
authenticatedMachineObject,
invalidTokenAuthObject,
signedInAuthObject,
signedOutAuthObject,
unauthenticatedMachineObject,
Expand All @@ -27,13 +29,15 @@ export const AuthStatus = {

export type AuthStatus = (typeof AuthStatus)[keyof typeof AuthStatus];

type ToAuth<T extends TokenType, Authenticated extends boolean> = T extends SessionTokenType
? Authenticated extends true
? (opts?: PendingSessionOptions) => SignedInAuthObject
: () => SignedOutAuthObject
: Authenticated extends true
? () => AuthenticatedMachineObject<Exclude<T, SessionTokenType>>
: () => UnauthenticatedMachineObject<Exclude<T, SessionTokenType>>;
type ToAuth<T extends TokenType | null, Authenticated extends boolean> = T extends null
? () => InvalidTokenAuthObject
: T extends SessionTokenType
? Authenticated extends true
? (opts?: PendingSessionOptions) => SignedInAuthObject
: () => SignedOutAuthObject
: Authenticated extends true
? () => AuthenticatedMachineObject<Exclude<T, SessionTokenType | null>>
: () => UnauthenticatedMachineObject<Exclude<T, SessionTokenType | null>>;

export type AuthenticatedState<T extends TokenType = SessionTokenType> = {
status: typeof AuthStatus.SignedIn;
Expand All @@ -58,7 +62,7 @@ export type AuthenticatedState<T extends TokenType = SessionTokenType> = {
toAuth: ToAuth<T, true>;
};

export type UnauthenticatedState<T extends TokenType = SessionTokenType> = {
export type UnauthenticatedState<T extends TokenType | null = SessionTokenType> = {
status: typeof AuthStatus.SignedOut;
reason: AuthReason;
message: string;
Expand Down Expand Up @@ -120,8 +124,8 @@ export type AuthErrorReason = (typeof AuthErrorReason)[keyof typeof AuthErrorRea

export type AuthReason = AuthErrorReason | TokenVerificationErrorReason;

export type RequestState<T extends TokenType = SessionTokenType> =
| AuthenticatedState<T>
export type RequestState<T extends TokenType | null = SessionTokenType> =
| AuthenticatedState<T extends null ? never : T>
| UnauthenticatedState<T>
| (T extends SessionTokenType ? HandshakeState : never);

Expand Down Expand Up @@ -240,6 +244,29 @@ export function handshake(
});
}

export function signedOutInvalidToken(): UnauthenticatedState<null> {
const authObject = invalidTokenAuthObject();
return withDebugHeaders({
status: AuthStatus.SignedOut,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
proxyUrl: '',
publishableKey: '',
isSatellite: false,
domain: '',
signInUrl: '',
signUpUrl: '',
afterSignInUrl: '',
afterSignUpUrl: '',
isSignedIn: false,
isAuthenticated: false,
tokenType: null,
toAuth: () => authObject,
headers: new Headers(),
token: null,
});
}

const withDebugHeaders = <T extends { headers: Headers; message?: string; reason?: AuthReason; status?: AuthStatus }>(
requestState: T,
): T => {
Expand Down
32 changes: 26 additions & 6 deletions packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { AuthenticateContext } from './authenticateContext';
import { createAuthenticateContext } from './authenticateContext';
import type { SignedInAuthObject } from './authObjects';
import type { HandshakeState, RequestState, SignedInState, SignedOutState, UnauthenticatedState } from './authStatus';
import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus';
import { AuthErrorReason, handshake, signedIn, signedOut, signedOutInvalidToken } from './authStatus';
import { createClerkRequest } from './clerkRequest';
import { getCookieName, getCookieValue } from './cookie';
import { HandshakeService } from './handshake';
Expand Down Expand Up @@ -88,6 +88,20 @@ function checkTokenTypeMismatch(
return null;
}

function isTokenTypeInAcceptedArray(acceptsToken: TokenType[], authenticateContext: AuthenticateContext): boolean {
let parsedTokenType: TokenType | null = null;
const { tokenInHeader } = authenticateContext;
if (tokenInHeader) {
if (isMachineTokenByPrefix(tokenInHeader)) {
parsedTokenType = getMachineTokenType(tokenInHeader);
} else {
parsedTokenType = TokenType.SessionToken;
}
}
const typeToCheck = parsedTokenType ?? TokenType.SessionToken;
return isTokenTypeAccepted(typeToCheck, acceptsToken);
}

export interface AuthenticateRequest {
/**
* @example
Expand All @@ -96,7 +110,7 @@ export interface AuthenticateRequest {
<T extends readonly TokenType[]>(
request: Request,
options: AuthenticateRequestOptions & { acceptsToken: T },
): Promise<RequestState<T[number]>>;
): Promise<RequestState<T[number] | null>>;

/**
* @example
Expand All @@ -123,7 +137,7 @@ export interface AuthenticateRequest {
export const authenticateRequest: AuthenticateRequest = (async (
request: Request,
options: AuthenticateRequestOptions,
): Promise<RequestState<TokenType>> => {
): Promise<RequestState<TokenType> | UnauthenticatedState<null>> => {
const authenticateContext = await createAuthenticateContext(createClerkRequest(request), options);
assertValidSecretKey(authenticateContext.secretKey);

Expand Down Expand Up @@ -655,7 +669,7 @@ export const authenticateRequest: AuthenticateRequest = (async (
// Handle case where tokenType is any and the token is not a machine token
if (!isMachineTokenByPrefix(tokenInHeader)) {
return signedOut({
tokenType: acceptsToken as MachineTokenType,
tokenType: acceptsToken as TokenType,
authenticateContext,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
Expand Down Expand Up @@ -722,15 +736,21 @@ export const authenticateRequest: AuthenticateRequest = (async (
});
}

// If acceptsToken is an array, early check if the token is in the accepted array
// to avoid unnecessary verification calls
if (Array.isArray(acceptsToken)) {
if (!isTokenTypeInAcceptedArray(acceptsToken, authenticateContext)) {
return signedOutInvalidToken();
}
}

if (authenticateContext.tokenInHeader) {
if (acceptsToken === 'any') {
return authenticateAnyRequestWithTokenInHeader();
}

if (acceptsToken === TokenType.SessionToken) {
return authenticateRequestWithTokenInHeader();
}

return authenticateMachineRequestWithTokenInHeader();
}

Expand Down
Loading
Loading