Skip to content

feat(session): add configurable session encoding with migration support#20

Open
nicknisi wants to merge 1 commit intomainfrom
feat/session-encoding-migration
Open

feat(session): add configurable session encoding with migration support#20
nicknisi wants to merge 1 commit intomainfrom
feat/session-encoding-migration

Conversation

@nicknisi
Copy link
Member

Summary

  • Adds a sessionEncoding config option to AuthKitConfig with independent read and write sub-options
  • read accepts 'sealed' | 'unsealed' | 'both' — the 'both' mode tries sealed (iron-webcrypto) first, falls back to base64url JSON
  • write accepts 'sealed' | 'unsealed' — unsealed writes base64url-encoded JSON instead of iron-encrypted data
  • Defaults to { read: 'sealed', write: 'sealed' } for full backward compatibility
  • cookiePassword is now optional when encoding is fully unsealed (read: 'unsealed', write: 'unsealed')
  • Supports env vars: WORKOS_SESSION_ENCODING_READ and WORKOS_SESSION_ENCODING_WRITE

Migration phases (seal → unseal)

  1. Start: { read: 'sealed', write: 'sealed' } — current behavior, no changes needed
  2. Transition: { read: 'both', write: 'unsealed' } — accept old sealed cookies, write new unsealed format. Over time as users re-authenticate, their cookies migrate.
  3. Complete: { read: 'unsealed', write: 'unsealed' } — all sessions are unsealed, cookiePassword no longer required

The reverse direction works the same way — set { read: 'both', write: 'sealed' } to re-seal sessions.

Test plan

  • All 151 existing tests pass without modification
  • New tests cover all encoding mode combinations:
    • sealed → sealed (default behavior, backward compatible)
    • unsealed → unsealed (fully unsealed)
    • both → unsealed (migration: seal-to-unseal)
    • both → sealed (migration: unseal-to-seal)
    • Error paths for invalid data in each mode
  • TypeScript strict mode passes (npx tsc --noEmit)
  • Build succeeds (pnpm run build)
  • Formatting passes (pnpm run prettier)
  • cookiePassword validation conditionally skipped when fully unsealed
  • Env var override tested for WORKOS_SESSION_ENCODING_READ / WORKOS_SESSION_ENCODING_WRITE

Reviewer notes

  • AuthKitCore.ts is at 316 lines (advisory limit is 300). The encodeUnsealed/decodeUnsealed helpers are already top-level functions outside the class. Could extract to a separate module in a follow-up.
  • No manual Playwright testing — this is a pure TypeScript library with no UI. Unit tests are the authoritative verification mechanism.
  • Teams adopting this feature should test read: 'both' mode with real cookies sealed by the previous iron-webcrypto version in their integration environment.

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-apps
Copy link

greptile-apps bot commented Mar 17, 2026

Greptile Summary

This PR introduces a sessionEncoding configuration option that lets applications migrate session cookies between iron-webcrypto sealed (encrypted + HMAC authenticated) and plain base64url JSON (unsealed) formats. A read: 'both' mode supports gradual migration by trying sealed decode first and falling back to unsealed. All 151 existing tests pass and 19 new tests cover the new modes.

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:

  • session.user and session.impersonator can be freely tampered by anyone who can write to their cookie storage. Neither field is cross-validated against the verified JWT claims downstream in validateAndRefresh / withAuth.
  • read: 'both' mode is vulnerable to forged unsealed cookies during the unsealed → sealed migration phase: a crafted unsealed JSON blob is accepted once the sealed decode fails, bypassing any shared-secret barrier.
  • Env vars WORKOS_SESSION_ENCODING_READ / WORKOS_SESSION_ENCODING_WRITE are not validated against their allowed union values before being cast, causing silent fallback to sealed behaviour on invalid input with no error surfaced.
  • decodeUnsealed has no runtime structural validation — a cookie that decodes to null or a partial object will propagate past decryptSession and surface as a confusing TypeError rather than a clean SessionEncryptionError.

Confidence Score: 2/5

  • Not safe to merge until the integrity gap in unsealed mode is addressed or explicitly accepted with documented security trade-offs.
  • The sealed default path is backward-compatible and safe. However, the unsealed path — which is the primary new capability — provides no integrity protection, meaning cookie data (user, impersonator, refreshToken) can be tampered without detection. This is an inherent architectural concern rather than a simple bug, and it affects both write: 'unsealed' and the read: 'both' migration path. Teams enabling unsealed mode may not realise their session cookies are unauthenticated. Additionally, env-var inputs are not validated, and the decoded structure has no runtime type guard.
  • src/core/AuthKitCore.ts — the decodeUnsealed helper and the read: 'both' fallback path in decryptSession carry the integrity concern. src/core/config/ConfigurationProvider.tsresolveSessionEncoding needs input validation.

Important Files Changed

Filename Overview
src/core/AuthKitCore.ts Adds encodeUnsealed/decodeUnsealed helpers and rewires encryptSession/decryptSession to branch on sessionEncoding config. The unsealed path performs no integrity verification, exposing session.user and session.impersonator to cookie tampering without detection.
src/core/config/ConfigurationProvider.ts Adds resolveSessionEncoding() for env-var-aware encoding config and conditionalises cookiePassword validation. Logic is mostly sound but env var values are not validated against the allowed string unions before being cast, which could lead to silent fallback to sealed behaviour on typo.
src/core/config/types.ts Adds the SessionEncoding interface and makes cookiePassword optional. Types are well-defined and backward-compatible.
src/core/AuthKitCore.spec.ts Comprehensive tests for all encoding-mode combinations (sealed/unsealed/both) on both encrypt and decrypt paths, including error paths. No issues found.
src/core/config/ConfigurationProvider.spec.ts Good coverage of new sessionEncoding defaults, programmatic config, env var overrides, and conditional cookiePassword validation. No issues found.

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
Loading

Last reviewed commit: b2e0050

Comment on lines +17 to +21
function decodeUnsealed(encoded: string): Session {
return JSON.parse(
Buffer.from(encoded, 'base64url').toString('utf-8'),
) as Session;
}
Copy link

Choose a reason for hiding this comment

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

P1 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:

  1. Decode the base64url cookie
  2. Modify the user object (email, name, metadata) or inject an impersonator field
  3. 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.

Comment on lines +87 to +95
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'],
};
Copy link

Choose a reason for hiding this comment

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

P1 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(', ')}`);
}

Comment on lines +17 to +21
function decodeUnsealed(encoded: string): Session {
return JSON.parse(
Buffer.from(encoded, 'base64url').toString('utf-8'),
) as Session;
}
Copy link

Choose a reason for hiding this comment

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

P2 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;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant