Skip to content
Open
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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<your 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="<your 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
Expand Down Expand Up @@ -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

Expand Down
6 changes: 2 additions & 4 deletions renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
8 changes: 7 additions & 1 deletion src/env-variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
129 changes: 127 additions & 2 deletions src/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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');
});
});
});
33 changes: 22 additions & 11 deletions src/session.ts
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.

🔴 Raw JSON in Set-Cookie header breaks when session data contains semicolons

After the migration from sealData (which produces URL-safe base64) to JSON.stringify, the raw JSON output is placed directly into a Set-Cookie header at line 319 without URL-encoding. If any session field (e.g., user's firstName, lastName, email, or metadata) contains a semicolon (;), the browser's cookie parser will interpret it as a cookie attribute delimiter, truncating the cookie value. This produces malformed JSON that fails to parse on subsequent requests, causing session loss.

Demonstration of the truncation

For a user with firstName: "John; Drop", the Set-Cookie header becomes:

wos-session={"accessToken":"eyJ...","user":{"firstName":"John; Drop"}}; Path=/; HttpOnly; ...

The browser parses the value as {"accessToken":"eyJ...","user":{"firstName":"John — everything after the first ; in the JSON is treated as cookie attributes. The stored cookie is malformed JSON that decryptSession cannot parse.

Note that saveSession (src/session.ts:640) uses nextCookies.set() which internally URL-encodes the value via the cookie package's serialize function, so it is not affected. Only the middleware refresh path at line 319, which manually constructs the raw header string, is vulnerable.

(Refers to line 319)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this was a real gap. The saveSession path was safe (nextCookies.set URL-encodes internally), but this middleware refresh path built the header string manually.

Fixed in 91654ee — the value is now wrapped in encodeURIComponent(). The cookie read side (request.cookies.get() / nextCookies.get()) auto-decodes, so decryptSession receives raw JSON as expected.

For reference, authkit-session was already safe here — its serializeCookie helper uses encodeURIComponent(value) at the serialization layer.

Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<T>(data: string): Promise<T> {
if (data.startsWith(IRON_SEAL_PREFIX)) {
return unsealData<T>(data, { password: WORKOS_COOKIE_PASSWORD });
}
return JSON.parse(data) as T;
}

async function updateSessionMiddleware(
Expand All @@ -129,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.',
);
}

Expand Down Expand Up @@ -310,7 +316,10 @@ 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
Expand Down Expand Up @@ -553,9 +562,11 @@ export async function getSessionFromCookie(request?: NextRequest) {
}

if (cookie) {
return unsealData<Session>(cookie.value, {
password: WORKOS_COOKIE_PASSWORD,
});
try {
return await decryptSession<Session>(cookie.value);
} catch {
return undefined;
}
}
}

Expand All @@ -573,7 +584,7 @@ async function getSessionFromHeader(): Promise<Session | undefined> {
const authHeader = headersList.get(sessionHeaderName);
if (!authHeader) return;

return unsealData<Session>(authHeader, { password: WORKOS_COOKIE_PASSWORD });
return decryptSession<Session>(authHeader);
}

function getReturnPathname(url: string): string {
Expand Down
38 changes: 37 additions & 1 deletion src/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,43 @@ export async function generateSession(overrides: Partial<User> = {}) {
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<User> = {}) {
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,
Expand Down