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
-
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).
-
Removed redundant JWT custom claims — firstName, lastName, and email are now backfilled from withAuth().user in sessionPayload(), saving ~100 bytes. Ready to remove from JWT template.
-
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)
Feature Request: Configurable Session Cookie Payload in AuthKit
Summary
The
wos-sessioncookie sealed by@workos-inc/authkit-nextjsexceeds 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 fullSessionobject into thewos-sessioncookie: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_flagsclaims alone account for 776 bytes — the single largest contributor to cookie size. We cannot remove them.2. User object is stored in full
The
userfield is the complete WorkOSUserobject returned byauthenticateWithRefreshToken(). There is no mechanism to select which user fields are included, and no hook to transform the session before it is sealed.idexternalIdemailfirstName/lastNameprofilePictureUrlemailVerifiedlastSignInAtcreatedAt/updatedAtlocaleobject"user"metadataFields like
profilePictureUrl,lastSignInAt,createdAt,updatedAt,locale, andobjectare never read from the session cookie. They add ~150-250 bytes of unnecessary payload.Measured size breakdown (org admin, via decode-session-cookie script)
accessToken(JWT)refreshTokenuserobjectTop JWT claims by size:
feature_flagspermissionsrolesorganizationMembershipIdorganizationIduserIdonboardedAtemailfirstNamelastNameImpact
What we've done on our side
Migrated all permission reads to API — Frontend no longer reads
permissionsfrom the JWT. All permission checks go through/users/me/permissionswith server-side caching (60s TTL).Removed redundant JWT custom claims —
firstName,lastName, andemailare now backfilled fromwithAuth().userinsessionPayload(), saving ~100 bytes. Ready to remove from JWT template.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
permissionsand/orfeature_flagsfrom 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:
sessionTransformhookAllow a transform function that runs before the session is sealed:
This is more flexible and handles edge cases like renaming fields or adding computed values. It would also let us slim down the
userobject (442 bytes, most fields unused from cookie).Option C:
sessionUserFieldsconfigurationAllow developers to specify which
Userfields to include in the sealed session:Fields not in the list are stripped before
encryptSession(). The fullUserobject is still available viawithAuth()by decoding from a secondary source (API call, or separate cookie).Option D: Split
userinto a separate cookieStore only
{ accessToken, refreshToken }in the primarywos-sessioncookie, and put theuserobject 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
Set-Cookiein 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.encryptSession— Same fragility concern.Environment
@workos-inc/authkit-nextjs: 2.17.0iron-session: 8.0.4