Skip to content

Session cookie exceeds 4096-byte browser limit — built-in claims (permissions, feature_flags) cannot be disabled #404

@tiffanybalc

Description

@tiffanybalc

Feature Request: Configurable Session Cookie Payload in AuthKit

Summary

The wos-session cookie sealed by @workos-inc/authkit-nextjs exceeds the browser's 4096-byte cookie limit for users with many permissions or long profile URLs. We need a way to control which claims and fields are included in the sealed session payload.

Problem

AuthKit's encryptSession() seals the full Session object into the wos-session cookie:

// @workos-inc/authkit-nextjs/dist/esm/session.js
async function encryptSession(session) {
  return sealData(session, { password: WORKOS_COOKIE_PASSWORD, ttl: 0 });
}

// Called with the full payload:
encryptSession({ accessToken, refreshToken, user, impersonator });

There are two problems:

1. Built-in JWT claims cannot be disabled

The access token JWT contains built-in claims that are injected by WorkOS automatically:

  • permissions — automatically populated from role → permission mapping (~352 bytes for an org admin with 15 permissions)
  • feature_flags — automatically populated from WorkOS feature flags (~424 bytes)

These claims are not part of the JWT template — we confirmed this by inspecting the JWT template in Dashboard > Authentication > Sessions > Configure JWT template. The permissions and feature_flags claims appear in the token preview but are not editable or removable.

The permissions + feature_flags claims alone account for 776 bytes — the single largest contributor to cookie size. We cannot remove them.

2. User object is stored in full

The user field is the complete WorkOS User object returned by authenticateWithRefreshToken(). There is no mechanism to select which user fields are included, and no hook to transform the session before it is sealed.

Field Typical size Used at runtime?
id 25 bytes Yes - primary identifier
externalId 30 bytes Yes - maps to app user ID
email 20-40 bytes Rarely from cookie (also in JWT)
firstName / lastName 10-30 bytes Rarely from cookie (also in JWT custom claims)
profilePictureUrl 60-120 bytes No - fetched from app API
emailVerified 5 bytes No - only during onboarding
lastSignInAt 26 bytes No
createdAt / updatedAt 52 bytes No
locale 4-6 bytes No
object 6 bytes No - always "user"
metadata 2-200 bytes Depends on usage

Fields like profilePictureUrl, lastSignInAt, createdAt, updatedAt, locale, and object are never read from the session cookie. They add ~150-250 bytes of unnecessary payload.

Measured size breakdown (org admin, via decode-session-cookie script)

Component Bytes
accessToken (JWT) 2,323
refreshToken 27
user object 442
iron-session overhead ~200
Total sealed cookie 4,007

Top JWT claims by size:

Claim Bytes Removable?
feature_flags 424 No — built-in
permissions 352 No — built-in
roles 44 No — built-in
organizationMembershipId 31 Yes — JWT template
organizationId 27 Yes — but backend needs it
userId 27 Yes — but backend needs it
onboardedAt 26 Yes — JWT template
email 22 Removed — backfilled from user object
firstName 9 Removed — backfilled from user object
lastName 10 Removed — backfilled from user object

Impact

  • Users with many permissions cannot log in in Chrome — the browser silently drops the oversized cookie
  • The failure is silent — the user sees an auth loop with no error message
  • Firefox works intermittently (slightly higher limit) but Chrome always blocks
  • As permissions and feature flags grow, more users hit this cliff. It is not a one-time problem.

What we've done on our side

  1. Migrated all permission reads to API — Frontend no longer reads permissions from the JWT. All permission checks go through /users/me/permissions with server-side caching (60s TTL).

  2. Removed redundant JWT custom claimsfirstName, lastName, and email are now backfilled from withAuth().user in sessionPayload(), saving ~100 bytes. Ready to remove from JWT template.

  3. Measured cookie size — Built a decode script to unseal and inspect the cookie (scripts/decode-session-cookie.mjs).

Despite these changes, the cookie is still 4,007 bytes — dangerously close to the 4,096 limit in Chrome, and will exceed it as soon as another feature flag or permission is added.

Proposed solutions (any of these would work)

Option A: Allow disabling built-in claims (preferred)

Add a Dashboard setting or JWT template directive to exclude permissions and/or feature_flags from the access token. These claims are useful for some customers but not all — we enforce permissions server-side via FGA and can fetch feature flags via API.

Option B: sessionTransform hook

Allow a transform function that runs before the session is sealed:

export default authkitMiddleware({
  sessionTransform: (session) => ({
    ...session,
    user: {
      id: session.user.id,
      externalId: session.user.externalId,
      email: session.user.email,
    },
  }),
});

This is more flexible and handles edge cases like renaming fields or adding computed values. It would also let us slim down the user object (442 bytes, most fields unused from cookie).

Option C: sessionUserFields configuration

Allow developers to specify which User fields to include in the sealed session:

export default authkitMiddleware({
  sessionUserFields: ['id', 'externalId', 'email'],
});

Fields not in the list are stripped before encryptSession(). The full User object is still available via withAuth() by decoding from a secondary source (API call, or separate cookie).

Option D: Split user into a separate cookie

Store only { accessToken, refreshToken } in the primary wos-session cookie, and put the user object in a second cookie (e.g., wos-user). This keeps the critical auth tokens small and puts the user profile data — which changes infrequently — in a cookie that can be larger or omitted.

Option E: Server-side session storage

Support an optional server-side session store (Redis, database) where the full session is stored by session ID. The cookie only contains the session ID + HMAC. This completely eliminates the size constraint.

Workarounds we've considered

  • Intercepting Set-Cookie in middleware — Unseal the cookie after AuthKit writes it, strip fields, re-seal. Works but is fragile, depends on AuthKit internals, and breaks on version updates.
  • Forking encryptSession — Same fragility concern.
  • Shorter permission slugs — Saves ~160 bytes but doesn't address the structural problem and requires a breaking migration across the FGA schema.

Environment

  • @workos-inc/authkit-nextjs: 2.17.0
  • iron-session: 8.0.4
  • Next.js: 16.2.3
  • Browser cookie limit: 4096 bytes per cookie (enforced by Chrome per RFC 6265)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions