From c715f460d78ab38cc6b51b22ec2bbac49873ebaa Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 7 May 2026 14:54:58 -0700 Subject: [PATCH 1/5] refactor: drop cookie sealing with bidirectional migration Session cookies are now written as plain JSON instead of iron-sealed blobs. The reader detects the Fe26. prefix to transparently unseal legacy cookies, so existing sessions migrate on next refresh with zero downtime. PKCE state remains iron-sealed since those values appear in OAuth URL parameters. --- src/session.spec.ts | 125 ++++++++++++++++++++++++++++++++++++++++++++ src/session.ts | 26 +++++---- src/test-helpers.ts | 38 +++++++++++++- 3 files changed, 179 insertions(+), 10 deletions(-) diff --git a/src/session.spec.ts b/src/session.spec.ts index 72cb2c7..339a4b3 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -1170,4 +1170,129 @@ describe('session.ts', () => { }); }); }); + + describe('session sealing migration', () => { + it('should write session cookies as plain JSON, not iron-sealed', async () => { + const { saveSession } = await import('./session.js'); + + const sessionData = { + accessToken: await generateTestToken(), + refreshToken: 'test-refresh-token', + user: mockSession.user, + }; + + await saveSession(sessionData, 'https://example.com/callback'); + + const nextCookies = await cookies(); + const cookie = nextCookies.get('wos-session'); + expect(cookie).toBeDefined(); + + // Should be valid JSON, not an iron-sealed blob + expect(cookie!.value).not.toMatch(/^Fe26\./); + const parsed = JSON.parse(cookie!.value); + expect(parsed.accessToken).toBe(sessionData.accessToken); + expect(parsed.refreshToken).toBe(sessionData.refreshToken); + expect(parsed.user).toEqual(sessionData.user); + }); + + it('should read legacy iron-sealed session cookies', async () => { + mockSession.accessToken = await generateTestToken(); + + (jwtVerify as Mock).mockImplementation(() => true); + + const request = new NextRequest(new URL('http://example.com')); + request.cookies.set( + 'wos-session', + await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }), + ); + + const result = await updateSession(request); + + expect(result.session).toBeDefined(); + expect(result.session.user).toEqual(mockSession.user); + }); + + it('should read plain JSON session cookies', async () => { + mockSession.accessToken = await generateTestToken(); + + (jwtVerify as Mock).mockImplementation(() => true); + + const request = new NextRequest(new URL('http://example.com')); + request.cookies.set('wos-session', JSON.stringify(mockSession)); + + const result = await updateSession(request); + + expect(result.session).toBeDefined(); + expect(result.session.user).toEqual(mockSession.user); + }); + + it('should read legacy iron-sealed session from header', async () => { + mockSession.accessToken = await generateTestToken(); + + const nextHeaders = await headers(); + nextHeaders.set( + 'x-workos-session', + await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }), + ); + + const result = await withAuth(); + expect(result.user).toEqual(mockSession.user); + }); + + it('should read plain JSON session from header', async () => { + mockSession.accessToken = await generateTestToken(); + + const nextHeaders = await headers(); + nextHeaders.set('x-workos-session', JSON.stringify(mockSession)); + + const result = await withAuth(); + expect(result.user).toEqual(mockSession.user); + }); + + it('should rewrite iron-sealed session as plain JSON on refresh', async () => { + mockSession.accessToken = await generateTestToken({}, true); + + (jwtVerify as Mock).mockImplementation(() => { + throw new Error('Invalid token'); + }); + + const newAccessToken = await generateTestToken(); + vi.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({ + accessToken: newAccessToken, + refreshToken: 'new-refresh-token', + user: mockSession.user, + }); + + const request = new NextRequest(new URL('http://example.com')); + + // Plant an iron-sealed cookie (legacy format) + request.cookies.set( + 'wos-session', + await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }), + ); + + const result = await updateSession(request); + + // After refresh, the Set-Cookie should contain plain JSON, not iron-sealed + const setCookieHeader = result.headers.get('Set-Cookie'); + expect(setCookieHeader).toBeDefined(); + + const cookieValue = setCookieHeader!.split('wos-session=')[1].split(';')[0]; + expect(cookieValue).not.toMatch(/^Fe26\./); + const parsed = JSON.parse(decodeURIComponent(cookieValue)); + expect(parsed.accessToken).toBe(newAccessToken); + }); + + it('should gracefully handle malformed cookie values', async () => { + const request = new NextRequest(new URL('http://example.com')); + request.cookies.set('wos-session', 'not-json-and-not-iron'); + + const result = await updateSession(request, { + debug: true, + }); + + expect(result.session.user).toBeNull(); + expect(console.log).toHaveBeenCalledWith('No session found from cookie'); + }); + }); }); diff --git a/src/session.ts b/src/session.ts index 059f0a6..f4b7e27 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,6 +1,6 @@ 'use server'; -import { sealData, unsealData } from 'iron-session'; +import { unsealData } from 'iron-session'; import { JWTPayload, createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -108,11 +108,17 @@ function isInitialDocumentRequest(request: NextRequest): boolean { return isDocumentRequest && !isRSCRequest && !isPrefetch; } +const IRON_SEAL_PREFIX = 'Fe26.'; + async function encryptSession(session: Session) { - return sealData(session, { - password: WORKOS_COOKIE_PASSWORD, - ttl: 0, - }); + return JSON.stringify(session); +} + +async function decryptSession(data: string): Promise { + if (data.startsWith(IRON_SEAL_PREFIX)) { + return unsealData(data, { password: WORKOS_COOKIE_PASSWORD }); + } + return JSON.parse(data) as T; } async function updateSessionMiddleware( @@ -553,9 +559,11 @@ export async function getSessionFromCookie(request?: NextRequest) { } if (cookie) { - return unsealData(cookie.value, { - password: WORKOS_COOKIE_PASSWORD, - }); + try { + return await decryptSession(cookie.value); + } catch { + return undefined; + } } } @@ -573,7 +581,7 @@ async function getSessionFromHeader(): Promise { const authHeader = headersList.get(sessionHeaderName); if (!authHeader) return; - return unsealData(authHeader, { password: WORKOS_COOKIE_PASSWORD }); + return decryptSession(authHeader); } function getReturnPathname(url: string): string { diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 267dca3..b007928 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -54,7 +54,43 @@ export async function generateSession(overrides: Partial = {}) { org_id: 'org_123', }); - // Create and set a session cookie + const sessionData = JSON.stringify({ + accessToken, + refreshToken: 'refresh_token_123', + user: mockUser, + }); + + const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; + const nextCookies = await cookies(); + nextCookies.set(cookieName, sessionData); +} + +/** + * Create a legacy iron-sealed session cookie for backward compatibility tests. + */ +export async function generateSealedSession(overrides: Partial = {}) { + const mockUser = { + id: 'user_123', + email: 'test@example.com', + emailVerified: true, + profilePictureUrl: null, + firstName: 'Test', + lastName: 'User', + object: 'user', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + lastSignInAt: '2024-01-01T00:00:00Z', + externalId: null, + metadata: {}, + locale: null, + ...overrides, + } satisfies User; + + const accessToken = await generateTestToken({ + sid: 'session_123', + org_id: 'org_123', + }); + const encryptedSession = await sealData( { accessToken, From 437c29737fc0b3e92091c5b36a3c57944f6a8840 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 7 May 2026 14:56:39 -0700 Subject: [PATCH 2/5] chore: formatting --- renovate.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/renovate.json b/renovate.json index bd76507..9416b9f 100644 --- a/renovate.json +++ b/renovate.json @@ -3,9 +3,7 @@ "extends": ["github>workos/renovate-config"], "enabledManagers": ["github-actions"], "dependencyDashboard": false, - "schedule": [ - "on the 15th day of the month before 12pm" - ], + "schedule": ["on the 15th day of the month before 12pm"], "timezone": "UTC", - "rebaseWhen": "conflicted", + "rebaseWhen": "conflicted" } From 90adb027f3bef5dce006bc250e300275029ef0f9 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 7 May 2026 15:04:24 -0700 Subject: [PATCH 3/5] fix: URL-encode session cookie value in raw Set-Cookie header JSON values can contain semicolons (e.g. user names) which the browser interprets as cookie attribute delimiters, truncating the value. The saveSession path was safe (nextCookies.set URL-encodes), but the middleware refresh path built the header string manually. --- src/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/session.ts b/src/session.ts index f4b7e27..5baced8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -316,7 +316,7 @@ async function updateSession( authenticationMethod, }); - newRequestHeaders.append('Set-Cookie', `${cookieName}=${encryptedSession}; ${getCookieOptions(request.url, true)}`); + newRequestHeaders.append('Set-Cookie', `${cookieName}=${encodeURIComponent(encryptedSession)}; ${getCookieOptions(request.url, true)}`); newRequestHeaders.set(sessionHeaderName, encryptedSession); // Set JWT cookie if eagerAuth is enabled From 748ce468fde3ea51385131f96583c97f95f51810 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 7 May 2026 15:05:24 -0700 Subject: [PATCH 4/5] chore: formatting --- src/session.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/session.ts b/src/session.ts index 5baced8..902cedd 100644 --- a/src/session.ts +++ b/src/session.ts @@ -316,7 +316,10 @@ async function updateSession( authenticationMethod, }); - newRequestHeaders.append('Set-Cookie', `${cookieName}=${encodeURIComponent(encryptedSession)}; ${getCookieOptions(request.url, true)}`); + newRequestHeaders.append( + 'Set-Cookie', + `${cookieName}=${encodeURIComponent(encryptedSession)}; ${getCookieOptions(request.url, true)}`, + ); newRequestHeaders.set(sessionHeaderName, encryptedSession); // Set JWT cookie if eagerAuth is enabled From 84f9aad0086dd2dbf040398bf07e5fd50b7dc489 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 7 May 2026 16:13:24 -0700 Subject: [PATCH 5/5] refactor: make WORKOS_COOKIE_PASSWORD optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When not set, a password is derived from WORKOS_API_KEY and WORKOS_CLIENT_ID. Explicitly setting the env var still takes precedence — useful for cookie continuity across API key rotations or sharing sessions across domains. --- README.md | 11 ++++++++--- src/env-variables.ts | 8 +++++++- src/session.spec.ts | 4 ++-- src/session.ts | 2 +- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 793373e..61ad36e 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,16 @@ Make sure the following values are present in your `.env.local` environment vari ```sh WORKOS_CLIENT_ID="client_..." # retrieved from the WorkOS dashboard WORKOS_API_KEY="sk_test_..." # retrieved from the WorkOS dashboard -WORKOS_COOKIE_PASSWORD="" # generate a secure password here NEXT_PUBLIC_WORKOS_REDIRECT_URI="http://localhost:3000/callback" # configured in the WorkOS dashboard ``` -`WORKOS_COOKIE_PASSWORD` is the private key used to encrypt the session cookie. It has to be at least 32 characters long. You can use the [1Password generator](https://1password.com/password-generator/) or the `openssl` library to generate a strong password via the command line: +`WORKOS_COOKIE_PASSWORD` is optional. When not set, a password is automatically derived from your `WORKOS_API_KEY` and `WORKOS_CLIENT_ID`. You can set it explicitly if you need cookie continuity across API key rotations or want to share sessions across apps/domains: + +```sh +WORKOS_COOKIE_PASSWORD="" # optional, must be at least 32 characters +``` + +You can use the [1Password generator](https://1password.com/password-generator/) or the `openssl` library to generate a strong password via the command line: ``` openssl rand -base64 24 @@ -68,7 +73,7 @@ WORKOS_COOKIE_NAME='my-auth-cookie' > [!WARNING] > Setting `WORKOS_COOKIE_SAMESITE='none'` allows cookies to be sent in cross-origin contexts (like iframes), but reduces protection against CSRF attacks. This setting forces cookies to be secure (HTTPS only) and should only be used when absolutely necessary for your application architecture. -> [!TIP] >`WORKOS_COOKIE_DOMAIN` can be used to share WorkOS sessions between apps/domains. Note: The `WORKOS_COOKIE_PASSWORD` would need to be the same across apps/domains. Not needed for most use cases. +> [!TIP] >`WORKOS_COOKIE_DOMAIN` can be used to share WorkOS sessions between apps/domains. When sharing sessions, set `WORKOS_COOKIE_PASSWORD` explicitly to the same value across apps. Not needed for most use cases. ## Setup diff --git a/src/env-variables.ts b/src/env-variables.ts index 3b62722..b072d4c 100644 --- a/src/env-variables.ts +++ b/src/env-variables.ts @@ -17,9 +17,15 @@ const WORKOS_CLAIM_TOKEN = getEnvVariable('WORKOS_CLAIM_TOKEN'); // Required env variables const WORKOS_API_KEY = getEnvVariable('WORKOS_API_KEY') ?? ''; const WORKOS_CLIENT_ID = getEnvVariable('WORKOS_CLIENT_ID') ?? ''; -const WORKOS_COOKIE_PASSWORD = getEnvVariable('WORKOS_COOKIE_PASSWORD') ?? ''; +const WORKOS_COOKIE_PASSWORD = + getEnvVariable('WORKOS_COOKIE_PASSWORD') || deriveCookiePassword(WORKOS_API_KEY, WORKOS_CLIENT_ID); const WORKOS_REDIRECT_URI = process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI ?? ''; +function deriveCookiePassword(apiKey: string, clientId: string): string { + if (!apiKey || !clientId) return ''; + return `wos-ck:${apiKey}:${clientId}`; +} + export { WORKOS_API_HOSTNAME, WORKOS_API_HTTPS, diff --git a/src/session.spec.ts b/src/session.spec.ts index 339a4b3..4d9fd08 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -202,7 +202,7 @@ describe('session.ts', () => { [], ); }).rejects.toThrow( - 'You must provide a valid cookie password that is at least 32 characters in the environment variables.', + 'Cookie password must be at least 32 characters. Either set WORKOS_COOKIE_PASSWORD or ensure WORKOS_API_KEY and WORKOS_CLIENT_ID are configured.', ); setEnvVar(envVariables, 'WORKOS_COOKIE_PASSWORD', originalWorkosCookiePassword); @@ -225,7 +225,7 @@ describe('session.ts', () => { [], ); }).rejects.toThrow( - 'You must provide a valid cookie password that is at least 32 characters in the environment variables.', + 'Cookie password must be at least 32 characters. Either set WORKOS_COOKIE_PASSWORD or ensure WORKOS_API_KEY and WORKOS_CLIENT_ID are configured.', ); setEnvVar(envVariables, 'WORKOS_COOKIE_PASSWORD', originalWorkosCookiePassword); diff --git a/src/session.ts b/src/session.ts index 902cedd..7dcbe16 100644 --- a/src/session.ts +++ b/src/session.ts @@ -135,7 +135,7 @@ async function updateSessionMiddleware( if (!WORKOS_COOKIE_PASSWORD || WORKOS_COOKIE_PASSWORD.length < 32) { throw new Error( - 'You must provide a valid cookie password that is at least 32 characters in the environment variables.', + 'Cookie password must be at least 32 characters. Either set WORKOS_COOKIE_PASSWORD or ensure WORKOS_API_KEY and WORKOS_CLIENT_ID are configured.', ); }