diff --git a/README.md b/README.md
index bdab4c0..940607a 100644
--- a/README.md
+++ b/README.md
@@ -157,7 +157,7 @@ export const GET = async () => {
};
```
-In the [WorkOS dashboard](https://dashboard.workos.com), go to **Redirects** and set the **Sign-in URL** to match this route (e.g., `http://localhost:3000/sign-in`).
+In the [WorkOS dashboard](https://dashboard.workos.com), go to **Redirects** and set the **Sign-in URL** to match this route (e.g., `http://localhost:3000/sign-in`). Use a normal `` link to this route so navigation, not prefetching, starts sign-in.
> [!IMPORTANT]
> The Sign-in URL is required for features like [impersonation](https://workos.com/docs/user-management/impersonation) to work correctly. Without it, WorkOS-initiated flows (such as impersonating a user from the dashboard) will fail because they cannot complete the PKCE/CSRF verification that this library enforces on every callback.
@@ -388,32 +388,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo
For pages where you want to display a signed-in and signed-out view, use `withAuth` to retrieve the user session from WorkOS.
```jsx
-import Link from 'next/link';
-import { getSignInUrl, getSignUpUrl, withAuth, signOut } from '@workos-inc/authkit-nextjs';
+import { withAuth, signOut } from '@workos-inc/authkit-nextjs';
export default async function HomePage() {
// Retrieves the user from the session or returns `null` if no user is signed in
const { user } = await withAuth();
if (!user) {
- // Get the URL to redirect the user to AuthKit to sign in
- const signInUrl = await getSignInUrl();
-
- // Get the URL to redirect the user to AuthKit to sign up
- const signUpUrl = await getSignUpUrl();
-
- // You can also pass custom state data through the auth flow
- const signInUrlWithState = await getSignInUrl({
- state: JSON.stringify({
- teamId: 'team_123',
- referrer: 'homepage',
- }),
- });
-
return (
<>
- Log in
- Sign Up
+ Log in
+ Sign up
>
);
}
@@ -432,6 +417,18 @@ export default async function HomePage() {
}
```
+`getSignInUrl()` and `getSignUpUrl()` set a PKCE cookie, so call them in a Route Handler or Server Action, not while rendering a Server Component. Use the [sign-in route](#sign-in-url) above and an equivalent sign-up route:
+
+```ts
+// app/sign-up/route.ts
+import { getSignUpUrl } from '@workos-inc/authkit-nextjs';
+import { redirect } from 'next/navigation';
+
+export async function GET() {
+ redirect(await getSignUpUrl());
+}
+```
+
### Get the current user in a client component
For client components, use the `useAuth` hook to get the current user session.
@@ -511,7 +508,7 @@ const { user } = await withAuth({ ensureSignedIn: true });
const { user, loading } = useAuth({ ensureSignedIn: true });
```
-Enabling `ensureSignedIn` will redirect users to AuthKit if they attempt to access the page without being authenticated.
+Enabling `ensureSignedIn` will redirect users to AuthKit if they attempt to access the page without being authenticated. The Server Component guard first visits your existing `handleAuth()` callback route to set up PKCE; no additional route or configuration is needed. Rendering or prefetching the page does not set a verifier cookie. The cookie is created when the browser navigates to start authentication.
### Re-authentication
@@ -666,20 +663,32 @@ JWT tokens are sensitive credentials and should be handled carefully:
### Passing Custom State Through Authentication
-You can pass custom state data through the authentication flow using the `state` parameter. The state parameter is a string value that gets passed through OAuth and returned in the callback. To pass complex data, serialize it as JSON:
+You can pass custom state data through the authentication flow using the `state` parameter. The state parameter is a string value that gets passed through OAuth and returned in the callback. Generate the URL in a Route Handler or Server Action, serializing complex data as JSON:
```ts
-// When generating sign-in/sign-up URLs, serialize your data as JSON
-const signInUrl = await getSignInUrl({
- state: JSON.stringify({
- teamId: 'team_123',
- feature: 'billing',
- referrer: 'pricing-page',
- timestamp: Date.now(),
- }),
-});
+// app/sign-in/route.ts
+import { getSignInUrl } from '@workos-inc/authkit-nextjs';
+import { redirect } from 'next/navigation';
+
+export async function GET() {
+ const signInUrl = await getSignInUrl({
+ state: JSON.stringify({
+ teamId: 'team_123',
+ feature: 'billing',
+ referrer: 'pricing-page',
+ timestamp: Date.now(),
+ }),
+ });
+ redirect(signInUrl);
+}
+```
+
+The state data is available in the callback handler:
+
+```ts
+// app/callback/route.ts
+import { handleAuth } from '@workos-inc/authkit-nextjs';
-// The state data is available in the callback handler
export const GET = handleAuth({
onSuccess: async ({ user, state }) => {
// Parse the state string back to an object
diff --git a/src/auth-start.spec.ts b/src/auth-start.spec.ts
new file mode 100644
index 0000000..82114db
--- /dev/null
+++ b/src/auth-start.spec.ts
@@ -0,0 +1,226 @@
+import { createHash } from 'node:crypto';
+import { sealData, unsealData } from 'iron-session';
+import * as nextHeaders from 'next/headers';
+import { redirect } from 'next/navigation';
+import { NextRequest } from 'next/server';
+import { RequestCookies, ResponseCookies } from 'next/dist/server/web/spec-extension/cookies.js';
+import { RequestCookiesAdapter } from 'next/dist/server/web/spec-extension/adapters/request-cookies.js';
+import { handleAuth } from './authkit-callback-route.js';
+import { getPKCECookieNameForState, getStateFromPKCECookieValue } from './pkce.js';
+import { updateSessionMiddleware, withAuth } from './session.js';
+import { getWorkOS } from './workos.js';
+
+const callbackUri = 'https://preview.example/auth/callback?tenant=blue&label=hello%20world~';
+const documentHeaders = { accept: 'text/html' };
+const routingData = {
+ purpose: 'authkit-start',
+ redirectUri: callbackUri,
+ returnPathname: '/dashboard?tab=details',
+ screenHint: 'sign-in',
+};
+const sealOptions = { password: process.env.WORKOS_COOKIE_PASSWORD!, ttl: 0 };
+
+async function readFlow(response: Response) {
+ expect(response.status).toBe(307);
+ const url = new URL(response.headers.get('Location')!);
+ const state = url.searchParams.get('state')!;
+ const cookie = new ResponseCookies(response.headers).get(getPKCECookieNameForState(state));
+ expect(cookie?.value).toBe(state);
+ return { url, state, cookie: cookie!, data: await getStateFromPKCECookieValue(state) };
+}
+
+describe('page authentication start', () => {
+ let startUrl: string;
+
+ beforeEach(async () => {
+ const cookies = await nextHeaders.cookies();
+ // @ts-expect-error - _reset is part of the shared Next mock
+ cookies._reset();
+ const headers = await nextHeaders.headers();
+ // @ts-expect-error - _reset is part of the shared Next mock
+ headers._reset();
+ headers.set('x-workos-middleware', 'true');
+ headers.set('x-url', 'https://preview.example/dashboard?tab=details');
+ headers.set('x-redirect-uri', callbackUri);
+ await withAuth({ ensureSignedIn: true });
+ startUrl = vi.mocked(redirect).mock.calls.at(-1)![0];
+ vi.mocked(redirect).mockClear();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ it('redirects through the existing callback without writing render-time cookies', async () => {
+ const request = new NextRequest('https://preview.example/dashboard?tab=details', {
+ headers: documentHeaders,
+ });
+ const proxyResponse = await updateSessionMiddleware(
+ request,
+ false,
+ { enabled: false, unauthenticatedPaths: [] },
+ callbackUri,
+ ['/dashboard'],
+ );
+ expect(proxyResponse.headers.getSetCookie()).toEqual([]);
+
+ const headers = await nextHeaders.headers();
+ for (const name of proxyResponse.headers.get('x-middleware-override-headers')!.split(',')) {
+ headers.set(name, proxyResponse.headers.get(`x-middleware-request-${name}`)!);
+ }
+ const readonlyCookies = RequestCookiesAdapter.seal(new RequestCookies(new Headers()));
+ const cookieSpy = vi.spyOn(nextHeaders, 'cookies').mockResolvedValue(readonlyCookies);
+ await withAuth({ ensureSignedIn: true });
+ cookieSpy.mockRestore();
+
+ const start = new URL(vi.mocked(redirect).mock.calls[0][0]);
+ expect(start.origin + start.pathname).toBe('https://preview.example/auth/callback');
+ expect(start.searchParams.get('tenant')).toBe('blue');
+ expect(start.searchParams.get('state')).toBeNull();
+ expect(await unsealData(start.searchParams.get('__authkit_start')!, sealOptions)).toEqual({
+ ...routingData,
+ screenHint: 'sign-up',
+ });
+
+ const flow = await readFlow(await handleAuth()(new NextRequest(start, { headers: documentHeaders })));
+ expect(flow.url.origin).toBe('https://api.workos.com');
+ expect(flow.url.searchParams.get('redirect_uri')).toBe(callbackUri);
+ expect(flow.url.searchParams.get('screen_hint')).toBe('sign-up');
+ expect(flow.data.returnPathname).toBe('/dashboard?tab=details');
+ expect(flow.url.searchParams.get('code_challenge_method')).toBe('S256');
+ expect(flow.url.searchParams.get('code_challenge')).toBe(
+ createHash('sha256').update(flow.data.codeVerifier).digest('base64url'),
+ );
+ expect(flow.cookie).toMatchObject({ httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600 });
+ });
+
+ it('preserves the configured URI and cookie settings when baseURL corrects an internal callback origin', async () => {
+ const request = new NextRequest(startUrl.replace('https://preview.example', 'http://internal:3000'), {
+ headers: documentHeaders,
+ });
+ const flow = await readFlow(await handleAuth({ baseURL: 'https://preview.example' })(request));
+ expect(flow.url.searchParams.get('redirect_uri')).toBe(callbackUri);
+ expect(flow.cookie.secure).toBe(true);
+ expect(flow.data.returnPathname).toBe('/dashboard?tab=details');
+ });
+
+ it('starts fresh PKCE from a cached routing link rather than expiring the link', async () => {
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(Date.now() + 60 * 60 * 1000);
+ const flow = await readFlow(await handleAuth()(new NextRequest(startUrl, { headers: documentHeaders })));
+ expect(flow.cookie.maxAge).toBe(600);
+ expect(flow.data.returnPathname).toBe('/dashboard?tab=details');
+ });
+
+ it.each>([
+ { accept: '*/*', RSC: '1', 'Next-Router-State-Tree': '["",{}]' },
+ { accept: '*/*', RSC: '1', 'Next-Router-Prefetch': '1' },
+ { accept: 'text/html', Purpose: 'prefetch' },
+ { accept: 'text/html', 'Sec-Purpose': 'prefetch' },
+ ])('does not start a flow for a passive request with %j', async (headers) => {
+ const generate = vi.spyOn(getWorkOS().pkce, 'generate');
+ const handler = handleAuth();
+ const response = await handler(new NextRequest(startUrl, { headers }));
+ expect(response.status).toBe(200);
+ expect(response.headers.get('Content-Type')).toContain('text/html');
+ expect(response.headers.get('Cache-Control')).toContain('no-store');
+ expect(response.headers.get('Location')).toBeNull();
+ expect(response.headers.getSetCookie()).toEqual([]);
+ expect(await response.text()).toBe('');
+ expect(generate).not.toHaveBeenCalled();
+
+ // The same URL still starts authentication when Next falls back to a document navigation.
+ await readFlow(await handler(new NextRequest(startUrl, { headers: documentHeaders })));
+ expect(generate).toHaveBeenCalledTimes(1);
+ });
+
+ it('creates independent flows and treats code/state plus start parameters as a normal callback', async () => {
+ const handler = handleAuth();
+ const flowA = await readFlow(await handler(new NextRequest(startUrl, { headers: documentHeaders })));
+ const secondStart = new NextRequest(startUrl, { headers: documentHeaders });
+ secondStart.cookies.set(flowA.cookie.name, flowA.cookie.value);
+ const flowB = await readFlow(await handler(secondStart));
+ expect(flowB.state).not.toBe(flowA.state);
+ expect(flowB.data.codeVerifier).not.toBe(flowA.data.codeVerifier);
+ expect(flowB.cookie.name).not.toBe(flowA.cookie.name);
+
+ const callback = new NextRequest(startUrl, { headers: documentHeaders });
+ callback.nextUrl.searchParams.set('code', 'test-code');
+ callback.nextUrl.searchParams.set('state', flowA.state);
+ callback.cookies.set(flowA.cookie.name, flowA.cookie.value);
+ callback.cookies.set(flowB.cookie.name, flowB.cookie.value);
+ const exchange = vi
+ .spyOn(getWorkOS().userManagement, 'authenticateWithCode')
+ .mockRejectedValue(new Error('offline exchange stub'));
+ const response = await handler(callback);
+ expect(exchange).toHaveBeenCalledWith({
+ clientId: process.env.WORKOS_CLIENT_ID,
+ code: 'test-code',
+ codeVerifier: flowA.data.codeVerifier,
+ });
+ const cookies = response.headers.getSetCookie();
+ expect(cookies.find((cookie) => cookie.startsWith(`${flowA.cookie.name}=`))).toContain('Max-Age=0');
+ expect(cookies.some((cookie) => cookie.startsWith(`${flowB.cookie.name}=`))).toBe(false);
+
+ // Possessing a valid sealed state is not enough to create its cookie or exchange a code.
+ exchange.mockClear();
+ callback.cookies.delete(flowA.cookie.name);
+ const rejected = await handler(callback);
+ expect(rejected.status).toBe(500);
+ expect(exchange).not.toHaveBeenCalled();
+ expect(rejected.headers.getSetCookie().find((cookie) => cookie.startsWith(`${flowA.cookie.name}=`))).toContain(
+ 'Max-Age=0',
+ );
+
+ const substitutedState = new URL(startUrl);
+ substitutedState.searchParams.set('__authkit_start', flowA.state);
+ const onError = vi.fn(() => new Response(null, { status: 500 }));
+ const rejectedStart = await handleAuth({ onError })(
+ new NextRequest(substitutedState, { headers: documentHeaders }),
+ );
+ expect(rejectedStart.status).toBe(500);
+ expect(rejectedStart.headers.getSetCookie()).toEqual([]);
+ expect(exchange).not.toHaveBeenCalled();
+ expect(JSON.stringify(onError.mock.calls[0])).not.toContain(flowA.data.codeVerifier);
+ });
+
+ it.each([
+ { ...routingData, purpose: 'oauth-state' },
+ { ...routingData, redirectUri: 'not-a-url' },
+ { ...routingData, returnPathname: 'https://evil.example' },
+ { ...routingData, returnPathname: '//evil.example' },
+ { ...routingData, returnPathname: '/\\evil.example' },
+ { ...routingData, screenHint: 'unknown' },
+ { ...routingData, codeVerifier: 'not-routing-data' },
+ ])('rejects a sealed payload with the wrong routing shape: %j', async (data) => {
+ const generate = vi.spyOn(getWorkOS().pkce, 'generate');
+ const url = new URL(startUrl);
+ url.searchParams.set('__authkit_start', await sealData(data, sealOptions));
+ const response = await handleAuth()(new NextRequest(url, { headers: documentHeaders }));
+ expect(response.status).toBe(500);
+ expect(response.headers.getSetCookie()).toEqual([]);
+ expect(generate).not.toHaveBeenCalled();
+ });
+
+ it.each(['&__authkit_start=duplicate', '&code=', '&state='])(
+ 'does not bypass callback checks with %s',
+ async (suffix) => {
+ const generate = vi.spyOn(getWorkOS().pkce, 'generate');
+ const response = await handleAuth()(new NextRequest(startUrl + suffix, { headers: documentHeaders }));
+ expect(response.status).toBe(500);
+ expect(response.headers.getSetCookie()).toEqual([]);
+ expect(generate).not.toHaveBeenCalled();
+ },
+ );
+
+ it('rejects a tampered routing payload', async () => {
+ const url = new URL(startUrl);
+ const payload = url.searchParams.get('__authkit_start')!;
+ url.searchParams.set('__authkit_start', payload.replace('Fe26.2', 'invalid'));
+ const response = await handleAuth()(new NextRequest(url, { headers: documentHeaders }));
+ expect(response.status).toBe(500);
+ expect(response.headers.getSetCookie()).toEqual([]);
+ });
+});
diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts
index 326a6db..887b565 100644
--- a/src/authkit-callback-route.ts
+++ b/src/authkit-callback-route.ts
@@ -1,13 +1,29 @@
+import { unsealData } from 'iron-session';
import { NextRequest } from 'next/server';
+import * as v from 'valibot';
import { getPKCECookieOptions } from './cookie.js';
-import { WORKOS_CLIENT_ID } from './env-variables.js';
+import { WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD } from './env-variables.js';
import { CallbackError } from './errors.js';
+import { getAuthorizationUrl } from './get-authorization-url.js';
import { HandleAuthOptions } from './interfaces.js';
-import { PKCE_COOKIE_NAME, getPKCECookieNameForState, getStateFromPKCECookieValue } from './pkce.js';
+import {
+ PKCE_COOKIE_NAME,
+ appendPKCESetCookieHeader,
+ getPKCECookieNameForState,
+ getStateFromPKCECookieValue,
+ isInitialDocumentRequest,
+} from './pkce.js';
import { saveSession } from './session.js';
import { errorResponseWithFallback, redirectWithFallback, setCachePreventionHeaders } from './utils.js';
import { getWorkOS } from './workos.js';
+const AuthStartSchema = v.strictObject({
+ purpose: v.literal('authkit-start'),
+ redirectUri: v.pipe(v.string(), v.url()),
+ returnPathname: v.string(),
+ screenHint: v.picklist(['sign-in', 'sign-up']),
+});
+
function preventCaching(headers: Headers): void {
headers.set('Vary', 'Cookie');
setCachePreventionHeaders(headers);
@@ -32,6 +48,7 @@ export function handleAuth(options: HandleAuthOptions = {}) {
// Gather mandatory information
const code = requestUrl.searchParams.get('code');
const state = requestUrl.searchParams.get('state');
+ const authStart = requestUrl.searchParams.get('__authkit_start');
// Attribution for thrown errors: which request failed and what it carried,
// with param values omitted since `code` is a live credential.
@@ -46,6 +63,35 @@ export function handleAuth(options: HandleAuthOptions = {}) {
// destroying the 1-use PKCE cookie to prevent replay attacks or stale
// cookies affecting future auth attempts.
try {
+ if (code === null && state === null && authStart !== null) {
+ const routing = v.safeParse(
+ AuthStartSchema,
+ await unsealData(authStart, { password: WORKOS_COOKIE_PASSWORD, ttl: 0 }),
+ );
+ // Schema errors include their input. Do not expose decrypted data through logs or onError.
+ if (requestUrl.searchParams.getAll('__authkit_start').length !== 1 || !routing.success) {
+ throw new Error('Invalid authentication start request');
+ }
+ const { redirectUri, returnPathname, screenHint } = routing.output;
+ const returnUrl = new URL(returnPathname, redirectUri);
+ if (!returnPathname.startsWith('/') || returnUrl.origin !== new URL(redirectUri).origin) {
+ throw new Error('Authentication return path must be on the application origin');
+ }
+
+ const responseHeaders = new Headers();
+ preventCaching(responseHeaders);
+ if (!isInitialDocumentRequest(request)) {
+ // Next ignores non-Flight prefetches and uses a full navigation when the user follows the redirect.
+ responseHeaders.set('Content-Type', 'text/html; charset=utf-8');
+ return new Response('', { headers: responseHeaders });
+ }
+
+ // Use the configured URI, not Next's normalized request URL. Never reuse routing data as OAuth state.
+ const { url, sealedState } = await getAuthorizationUrl({ returnPathname, screenHint, redirectUri });
+ appendPKCESetCookieHeader(request, responseHeaders, sealedState, redirectUri);
+ return redirectWithFallback(url, responseHeaders);
+ }
+
if (!code || !state) {
throw new CallbackError('Missing required auth parameter', 'missing_auth_params', errorContext);
}
diff --git a/src/pkce.ts b/src/pkce.ts
index 52afa3c..0a8cd24 100644
--- a/src/pkce.ts
+++ b/src/pkce.ts
@@ -63,7 +63,12 @@ export function setPendingPKCERedirectHeaders(headers: Headers, authorizationUrl
* AuthKit. Fetch/XHR/RSC/prefetch requests never follow cross-origin redirects
* to complete OAuth, so they do not need verifier cookies.
*/
-export function appendPKCESetCookieHeader(request: NextRequest, headers: Headers, sealedState: string): void {
+export function appendPKCESetCookieHeader(
+ request: NextRequest,
+ headers: Headers,
+ sealedState: string,
+ cookieUrl = request.url,
+): void {
if (!isInitialDocumentRequest(request)) {
return;
}
@@ -76,7 +81,7 @@ export function appendPKCESetCookieHeader(request: NextRequest, headers: Headers
// A small number of concurrent PKCE cookies is normal (multiple tabs each
// starting an OAuth flow). Only purge when accumulation risks HTTP 431.
if (pkceCookies.length >= MAX_PKCE_COOKIES) {
- const expiredOptions = getPKCECookieOptions(request.url, true, true);
+ const expiredOptions = getPKCECookieOptions(cookieUrl, true, true);
for (const { name } of pkceCookies) {
if (name !== newCookieName) {
headers.append('Set-Cookie', `${name}=; ${expiredOptions}`);
@@ -84,7 +89,7 @@ export function appendPKCESetCookieHeader(request: NextRequest, headers: Headers
}
}
- headers.append('Set-Cookie', `${newCookieName}=${sealedState}; ${getPKCECookieOptions(request.url, true)}`);
+ headers.append('Set-Cookie', `${newCookieName}=${sealedState}; ${getPKCECookieOptions(cookieUrl, true)}`);
}
export function stripPKCESetCookieHeaders(headers: Headers): void {
diff --git a/src/session.spec.ts b/src/session.spec.ts
index 6298c8e..12a835b 100644
--- a/src/session.spec.ts
+++ b/src/session.spec.ts
@@ -20,9 +20,8 @@ import { SignJWT, jwtVerify } from 'jose';
function setEnvVar(mod: Record, key: string, value: unknown) {
Object.defineProperty(mod, key, { value, configurable: true });
}
-import { sealData } from 'iron-session';
+import { sealData, unsealData } from 'iron-session';
import { User } from '@workos-inc/node';
-import { getStateFromPKCECookieValue } from './pkce.js';
import { handleAuthkitHeaders } from './middleware-helpers.js';
vi.mock('jose', async () => {
@@ -198,12 +197,12 @@ describe('session.ts', () => {
await withAuth({ ensureSignedIn: true });
- // The state is now sealed, se we need to unseal it
- const redirectUrl = new URL((redirect as unknown as Mock).mock.calls[0][0]);
- const sealedState = redirectUrl.searchParams.get('state')!;
- const { returnPathname } = await getStateFromPKCECookieValue(sealedState);
-
- expect(returnPathname).toBe('/protected?test=123');
+ const redirectUrl = new URL(vi.mocked(redirect).mock.calls[0][0]);
+ const routing = await unsealData(redirectUrl.searchParams.get('__authkit_start')!, {
+ password: process.env.WORKOS_COOKIE_PASSWORD!,
+ ttl: 0,
+ });
+ expect(routing).toMatchObject({ returnPathname: '/protected?test=123' });
});
});
@@ -735,7 +734,12 @@ describe('session.ts', () => {
await withAuth({ ensureSignedIn: true });
expect(redirect).toHaveBeenCalledTimes(1);
- expect(redirect).toHaveBeenCalledWith(expect.stringContaining('screen_hint=sign-up'));
+ const redirectUrl = new URL(vi.mocked(redirect).mock.calls[0][0]);
+ const routing = await unsealData(redirectUrl.searchParams.get('__authkit_start')!, {
+ password: process.env.WORKOS_COOKIE_PASSWORD!,
+ ttl: 0,
+ });
+ expect(routing).toMatchObject({ screenHint: 'sign-up' });
});
});
});
diff --git a/src/session.ts b/src/session.ts
index 9cd410f..baa78f1 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -18,12 +18,7 @@ import {
Session,
UserInfo,
} from './interfaces.js';
-import {
- appendPKCESetCookieHeader,
- isInitialDocumentRequest,
- setPKCECookie,
- setPendingPKCERedirectHeaders,
-} from './pkce.js';
+import { appendPKCESetCookieHeader, isInitialDocumentRequest, setPendingPKCERedirectHeaders } from './pkce.js';
import { getWorkOS } from './workos.js';
import type { AuthenticationResponse } from '@workos-inc/node';
@@ -496,11 +491,16 @@ async function redirectToSignIn() {
const pathname = new URL(url).pathname;
const screenHint = getScreenHint(signUpPaths, pathname);
- const returnPathname = getReturnPathname(url);
-
- const { url: authkitUrl, sealedState } = await getAuthorizationUrl({ returnPathname, screenHint });
- await setPKCECookie(sealedState);
- redirect(authkitUrl);
+ // Server Components cannot write cookies. Start PKCE on a document request to the existing callback instead.
+ const redirectUri = headersList.get('x-redirect-uri') ?? WORKOS_REDIRECT_URI;
+ const startUrl = new URL(redirectUri);
+ const routing = await sealData(
+ { purpose: 'authkit-start', redirectUri, returnPathname: getReturnPathname(url), screenHint },
+ // Routing data is not an OAuth credential; a cached link can always start a fresh flow.
+ { password: WORKOS_COOKIE_PASSWORD, ttl: 0 },
+ );
+ startUrl.searchParams.set('__authkit_start', routing);
+ redirect(startUrl.toString());
}
export async function getTokenClaims>(