refactor: drop cookie sealing with bidirectional migration#424
refactor: drop cookie sealing with bidirectional migration#424nicknisi wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
🔴 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)
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.
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.
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.
e336657 to
748ce46
Compare
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.
Closes #425
Summary
wos-session) are now written as plain JSON instead of iron-sealed blobsFe26.prefix — existing iron-sealed cookies are transparently unsealedstateURL parametersWhy this is safe
httpOnly,secure,sameSite=lax— browser JS cannot read themcodeVerifierFe26.prefix — no collision with JSON (JSON.stringifyalways starts with{,[, or")Migration behavior
Seamless. The adapter reads both formats, so existing users with iron-sealed cookies are never rejected. On next token refresh the cookie is rewritten as JSON. No re-auth, no downtime.
Mirrors the approach in workos/authkit-session#31.