feat(session): add configurable session encoding with migration support#20
feat(session): add configurable session encoding with migration support#20
Conversation
Introduces a SessionEncoding config (read/write) that allows gradual migration between iron-sealed and base64url-JSON session formats: - read:sealed (default) -- iron-unseal only - read:unsealed -- base64url decode only - read:both -- try sealed first, fall back to unsealed - write:sealed (default) -- iron-encrypt - write:unsealed -- write plain base64url JSON cookiePassword is now optional when encoding is fully unsealed. WORKOS_SESSION_ENCODING_READ and WORKOS_SESSION_ENCODING_WRITE env vars override programmatic config.
Greptile SummaryThis PR introduces a The core concern is integrity: iron-webcrypto's sealed format provides both encryption and HMAC authentication, so any cookie modification is detected and rejected. The new unsealed format is bare base64url-encoded JSON with no signature or HMAC. This means:
Confidence Score: 2/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
subgraph encryptSession["encryptSession(session)"]
E1{write mode?} -->|unsealed| E2[encodeUnsealed\nbase64url JSON]
E1 -->|sealed default| E3{cookiePassword\npresent?}
E3 -->|no| E4[throw SessionEncryptionError]
E3 -->|yes| E5[iron-webcrypto\nsealData — encrypted + HMAC]
end
subgraph decryptSession["decryptSession(encoded)"]
D1{read mode?}
D1 -->|unsealed| D2[decodeUnsealed\nbase64url → JSON.parse\nno integrity check ⚠️]
D1 -->|both| D3[unsealSession\ntry sealed first]
D3 -->|success| D6[return Session]
D3 -->|failure| D4[decodeUnsealed\nfallback — no integrity check ⚠️]
D4 -->|success| D6
D4 -->|failure| D5[throw SessionEncryptionError]
D1 -->|sealed default| D7[unsealSession\niron-webcrypto — encrypted + HMAC]
D7 --> D6
D2 --> D6
end
subgraph validate["ConfigurationProvider.validate()"]
V1{read=unsealed AND\nwrite=unsealed?}
V1 -->|yes| V2[cookiePassword\nnot required]
V1 -->|no| V3[cookiePassword\nrequired + length ≥ 32]
end
Last reviewed commit: b2e0050 |
| function decodeUnsealed(encoded: string): Session { | ||
| return JSON.parse( | ||
| Buffer.from(encoded, 'base64url').toString('utf-8'), | ||
| ) as Session; | ||
| } |
There was a problem hiding this comment.
No tamper protection on unsealed sessions
decodeUnsealed performs a bare JSON.parse with a type cast but no integrity verification. In contrast, sealed mode uses iron-webcrypto which provides both encryption and HMAC authentication — meaning any cookie modification is detected and rejected.
In unsealed mode, a user who can access their browser's cookie storage can:
- Decode the base64url cookie
- Modify the
userobject (email, name, metadata) or inject animpersonatorfield - Re-encode and submit the tampered cookie
This is especially problematic because validateAndRefresh returns session (the cookie-derived object) directly when the JWT is still valid — and there is no cross-validation between the verified claims.sub and the unverified session.user.id. The caller therefore receives a fully trusted auth.user and auth.impersonator that are entirely unauthenticated in unsealed mode.
The issue also affects read: 'both' mode. When migrating from unsealed → sealed ({ read: 'both', write: 'sealed' }), an attacker can deliberately present a hand-crafted unsealed JSON blob: unsealSession will fail on it (not a valid iron token), and the fallback path in decryptSession will accept it without any password or integrity check.
If a pure-encoding format (no crypto) is the intended long-term design, consider at minimum adding an HMAC signature over the JSON payload with the cookiePassword so tampered cookies are rejected — or document clearly that applications must not rely on session-cookie user data for any authoritative identity or access-control decision and instead use claims (the verified JWT payload) exclusively.
| const readEnv = this.getEnvironmentValue('WORKOS_SESSION_ENCODING_READ'); | ||
| const writeEnv = this.getEnvironmentValue('WORKOS_SESSION_ENCODING_WRITE'); | ||
| if (!readEnv && !writeEnv) { | ||
| return configValue; | ||
| } | ||
| return { | ||
| read: (readEnv ?? configValue.read) as SessionEncoding['read'], | ||
| write: (writeEnv ?? configValue.write) as SessionEncoding['write'], | ||
| }; |
There was a problem hiding this comment.
Env var values not validated against allowed values
WORKOS_SESSION_ENCODING_READ and WORKOS_SESSION_ENCODING_WRITE are cast directly to the union types without checking that the values are actually valid members ('sealed' | 'unsealed' | 'both'). An operator typo like WORKOS_SESSION_ENCODING_READ=botH or =unsaled will silently fall through to sealed behavior in decryptSession (since neither 'unsealed' nor 'both' branches match), leading to confusing runtime behavior with no error surfaced.
Consider adding validation in resolveSessionEncoding (or inside validate()) similar to:
const VALID_READ = ['sealed', 'unsealed', 'both'] as const;
const VALID_WRITE = ['sealed', 'unsealed'] as const;
if (readEnv && !VALID_READ.includes(readEnv as SessionEncoding['read'])) {
throw new Error(`Invalid WORKOS_SESSION_ENCODING_READ value: "${readEnv}". Expected one of: ${VALID_READ.join(', ')}`);
}| function decodeUnsealed(encoded: string): Session { | ||
| return JSON.parse( | ||
| Buffer.from(encoded, 'base64url').toString('utf-8'), | ||
| ) as Session; | ||
| } |
There was a problem hiding this comment.
No runtime validation of decoded session shape
JSON.parse(...) as Session is a purely compile-time cast. If the cookie value decodes to null, a non-object, or an object missing required fields like accessToken / user, the downstream code in validateAndRefresh will throw a TypeError (e.g., Cannot read properties of null (reading 'accessToken')) rather than a clean SessionEncryptionError.
Since errors from decryptSession are swallowed in AuthService.withAuth's outer catch block (returning { user: null }), this may be silently tolerable, but surfacing a proper SessionEncryptionError earlier with a useful message would improve debuggability. Consider adding a minimal structural guard:
function decodeUnsealed(encoded: string): Session {
const parsed = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf-8'));
if (!parsed || typeof parsed !== 'object' || !parsed.accessToken || !parsed.refreshToken) {
throw new Error('Decoded value is not a valid Session');
}
return parsed as Session;
}
Summary
sessionEncodingconfig option toAuthKitConfigwith independentreadandwritesub-optionsreadaccepts'sealed'|'unsealed'|'both'— the'both'mode tries sealed (iron-webcrypto) first, falls back to base64url JSONwriteaccepts'sealed'|'unsealed'— unsealed writes base64url-encoded JSON instead of iron-encrypted data{ read: 'sealed', write: 'sealed' }for full backward compatibilitycookiePasswordis now optional when encoding is fully unsealed (read: 'unsealed',write: 'unsealed')WORKOS_SESSION_ENCODING_READandWORKOS_SESSION_ENCODING_WRITEMigration phases (seal → unseal)
{ read: 'sealed', write: 'sealed' }— current behavior, no changes needed{ read: 'both', write: 'unsealed' }— accept old sealed cookies, write new unsealed format. Over time as users re-authenticate, their cookies migrate.{ read: 'unsealed', write: 'unsealed' }— all sessions are unsealed,cookiePasswordno longer requiredThe reverse direction works the same way — set
{ read: 'both', write: 'sealed' }to re-seal sessions.Test plan
npx tsc --noEmit)pnpm run build)pnpm run prettier)cookiePasswordvalidation conditionally skipped when fully unsealedWORKOS_SESSION_ENCODING_READ/WORKOS_SESSION_ENCODING_WRITEReviewer notes
AuthKitCore.tsis at 316 lines (advisory limit is 300). TheencodeUnsealed/decodeUnsealedhelpers are already top-level functions outside the class. Could extract to a separate module in a follow-up.read: 'both'mode with real cookies sealed by the previous iron-webcrypto version in their integration environment.