Skip to content

Commit 70d5e2b

Browse files
committed
fix(nextjs): enforce middleware authorization during keyless bootstrap
Previously, `clerkMiddleware` early-returned `NextResponse.next()` when it detected no publishable key (the client-side keyless bootstrap window), which skipped the user's handler entirely. Any authorization logic inside that handler — including `auth.protect()` — was bypassed during the bootstrap window. The fix runs the user's handler against a synthetic signed-out RequestState (via the new `createBootstrapSignedOutState` helper in @clerk/backend/internal) through the same post-authentication pipeline as the normal path. Authorization fails closed during bootstrap; `<ClerkProvider/>` downstream resumes the flow once keys are provisioned client-side. Dev-only path — production behavior is unchanged. Closes SDK-70.
1 parent 7a423d6 commit 70d5e2b

3 files changed

Lines changed: 102 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/nextjs': patch
3+
---
4+
5+
Fix a middleware-bypass window in keyless mode. When `clerkMiddleware` runs before a publishable key has been provisioned (the client-side keyless bootstrap window), the user's middleware handler now runs against a synthetic signed-out `RequestState` instead of being skipped. Authorization logic (`auth.protect()`, custom checks) is enforced fail-closed during bootstrap; `<ClerkProvider/>` downstream resumes the flow once keys are available. Dev-only path — production behavior is unchanged.

packages/backend/src/tokens/__tests__/authStatus.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
33

44
import { mockTokens, mockVerificationResults } from '../../fixtures/machine';
55
import type { AuthenticateContext } from '../../tokens/authenticateContext';
6-
import { handshake, signedIn, signedOut } from '../authStatus';
6+
import { createBootstrapSignedOutState, handshake, signedIn, signedOut } from '../authStatus';
77

88
describe('signed-in', () => {
99
describe('session tokens', () => {
@@ -132,6 +132,48 @@ describe('signed-out', () => {
132132
});
133133
});
134134

135+
describe('createBootstrapSignedOutState', () => {
136+
it('returns a signed-out session_token state with no publishable key', () => {
137+
const state = createBootstrapSignedOutState();
138+
139+
expect(state.status).toBe('signed-out');
140+
expect(state.tokenType).toBe('session_token');
141+
expect(state.isSignedIn).toBe(false);
142+
expect(state.isAuthenticated).toBe(false);
143+
expect(state.publishableKey).toBe('');
144+
expect(state.token).toBeNull();
145+
});
146+
147+
it('applies provided signInUrl and signUpUrl', () => {
148+
const state = createBootstrapSignedOutState({
149+
signInUrl: '/sign-in',
150+
signUpUrl: '/sign-up',
151+
});
152+
153+
expect(state.signInUrl).toBe('/sign-in');
154+
expect(state.signUpUrl).toBe('/sign-up');
155+
});
156+
157+
it('toAuth() returns a signed-out auth object without throwing', () => {
158+
const authObject = createBootstrapSignedOutState().toAuth();
159+
160+
expect(authObject.userId).toBeNull();
161+
expect(authObject.sessionId).toBeNull();
162+
expect(authObject.tokenType).toBe('session_token');
163+
});
164+
165+
it('includes debug headers on the state', () => {
166+
const state = createBootstrapSignedOutState({
167+
reason: 'session-token-and-uat-missing',
168+
message: 'no keys yet',
169+
});
170+
171+
expect(state.headers.get('x-clerk-auth-status')).toBe('signed-out');
172+
expect(state.headers.get('x-clerk-auth-reason')).toBe('session-token-and-uat-missing');
173+
expect(state.headers.get('x-clerk-auth-message')).toBe('no keys yet');
174+
});
175+
});
176+
135177
describe('handshake', () => {
136178
it('includes debug headers', () => {
137179
const headers = new Headers({ location: '/' });

packages/nextjs/src/server/clerkMiddleware.ts

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
import {
1313
AuthStatus,
1414
constants,
15+
createBootstrapSignedOutState,
1516
createClerkRequest,
1617
createRedirect,
1718
getAuthObjectForAcceptedToken,
@@ -230,6 +231,54 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
230231
});
231232
});
232233

234+
// Bootstrap path for the keyless window where no publishable key is available yet.
235+
// The real authenticateRequest() can't run without keys, so we synthesize a signed-out
236+
// RequestState and run the user's handler against it. This closes the middleware-bypass
237+
// window: authorization logic (e.g. auth.protect()) fail-closed during bootstrap instead
238+
// of being skipped entirely.
239+
const bootstrapNextMiddleware: NextMiddleware = withLogger(
240+
'clerkMiddleware',
241+
logger => async (request, event) => {
242+
const resolvedParams = typeof params === 'function' ? await params(request) : params;
243+
const keyless = await getKeylessCookieValue(name => request.cookies.get(name)?.value);
244+
245+
const signInUrl = resolvedParams.signInUrl || SIGN_IN_URL || '';
246+
const signUpUrl = resolvedParams.signUpUrl || SIGN_UP_URL || '';
247+
248+
const options = {
249+
publishableKey: '',
250+
secretKey: '',
251+
signInUrl,
252+
signUpUrl,
253+
...resolvedParams,
254+
};
255+
256+
clerkMiddlewareRequestDataStore.set('requestData', options);
257+
258+
if (options.debug) {
259+
logger.enable();
260+
}
261+
262+
const clerkRequest = createClerkRequest(request);
263+
logger.debug('keyless bootstrap (no publishable key)', () => ({ signInUrl, signUpUrl }));
264+
logger.debug('url', () => clerkRequest.toJSON());
265+
266+
const requestState = createBootstrapSignedOutState({ signInUrl, signUpUrl });
267+
268+
return runHandlerWithRequestState({
269+
clerkRequest,
270+
request,
271+
event,
272+
requestState,
273+
handler,
274+
options,
275+
resolvedParams,
276+
keyless,
277+
logger,
278+
});
279+
},
280+
);
281+
233282
const keylessMiddleware: NextMiddleware = async (request, event) => {
234283
/**
235284
* This mechanism replaces a full-page reload. Ensures that middleware will re-run and authenticate the request properly without the secret key or publishable key to be missing.
@@ -245,14 +294,13 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
245294
const authHeader = getHeader(request, constants.Headers.Authorization)?.replace('Bearer ', '') ?? '';
246295

247296
/**
248-
* In keyless mode, if the publishable key is missing, let the request through, to render `<ClerkProvider/>` that will resume the flow gracefully.
297+
* In keyless mode, when no publishable key is available yet, we still run the user's
298+
* middleware handler — against a synthetic signed-out RequestState — so authorization
299+
* logic is enforced during the bootstrap window. `<ClerkProvider/>` downstream resumes
300+
* the flow once keys are provisioned client-side.
249301
*/
250302
if (isMissingPublishableKey && !isMachineTokenByPrefix(authHeader)) {
251-
const res = NextResponse.next();
252-
setRequestHeadersOnNextResponse(res, request, {
253-
[constants.Headers.AuthStatus]: 'signed-out',
254-
});
255-
return res;
303+
return bootstrapNextMiddleware(request, event);
256304
}
257305

258306
return baseNextMiddleware(request, event);

0 commit comments

Comments
 (0)