fix(front): store auth tokenPair in localStorage instead of a cookie (#21507)

## Problem

A client hit an AWS S3 `RequestHeaderSectionTooLarge` error
(`MaxSizeAllowed 8192`) when opening a
`https://<workspace>.twenty.com/verify?loginToken=<JWT>` link — the
request to load the `/verify` SPA page is served from S3, which rejects
it before the app loads.

The dominant cause is the **`tokenPair` cookie**. The auth tokenPair
(access + refresh JWTs, ~2–5KB) was persisted in a host-scoped,
JS-readable cookie. Nothing server-side ever reads it — the access token
is sent to the API via an `Authorization: Bearer` header set in the
Apollo auth link (`ExtractJwt.fromAuthHeaderAsBearerToken()` on the
backend; no `cookie-parser`). Yet the browser attached that cookie to
**every** request to the origin, including static assets and the
`/verify` page. Combined with the `loginToken` in the URL, the request
header section exceeds S3's 8192-byte limit.

## Fix

Move `tokenPair` from cookie storage to **localStorage**, which is never
transmitted in request headers.

- `tokenPairState` now uses `useLocalStorage` (with `getOnInit: true`).
- `getTokenPair` (the synchronous read used by the Apollo auth link)
reads from localStorage under the same key.
- A one-time migration (`migrateTokenPairCookieToLocalStorage`) runs
before React renders: it ports any existing `tokenPair` cookie into
localStorage and **deletes the cookie**, so already-authenticated users
aren't logged out and the oversized cookie stops being sent.

## Why this is safe

**Behavior:** equivalent. The cookie was host-scoped (no `domain`
attribute), so it never provided cross-subdomain sharing —
cross-workspace auth already re-establishes the token per-origin via the
`loginToken`-in-URL → `/verify` handoff. localStorage has identical
origin scoping.

**Security:** neutral-to-positive.
- No XSS protection lost — the cookie was **not** `httpOnly` (it can't
be; JS reads it to build the Bearer header), so it was already
XSS-exposed exactly like localStorage.
- No CSRF surface change — the token was never sent as a cookie
credential (no `credentials: 'include'`).
- **Reduced exposure** — the token no longer leaks into CDN/proxy/server
access logs or request headers, which is the actual bug.
- Server-side revocation (`revokedAt`) and the 60-day refresh-token JWT
expiry govern validity, so localStorage's lack of auto-expiry is moot.

## Testing

- `getTokenPair` unit tests updated to localStorage.
- New unit tests for the migration util (port, no-op, no-clobber,
error-safety).
- `nx test twenty-front` auth + apollo suites: 125 passing.
- `lint:diff-with-main` clean; changed files typecheck clean.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21507?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Charles Bochet
2026-06-12 22:39:20 +02:00
committed by GitHub
parent 84a8504473
commit d22fa377e7
8 changed files with 171 additions and 88 deletions
@@ -0,0 +1,41 @@
import { createStore } from 'jotai';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
type Stored = { token: string } | null;
const validateInitFn = (payload: NonNullable<Stored>) =>
typeof payload.token === 'string';
describe('createAtomState validated localStorage', () => {
afterEach(() => {
localStorage.clear();
});
it('hydrates a persisted value that passes validateInitFn', () => {
localStorage.setItem('validAtom', JSON.stringify({ token: 'abc' }));
const state = createAtomState<Stored>({
key: 'validAtom',
defaultValue: null,
useLocalStorage: true,
localStorageOptions: { getOnInit: true },
validateInitFn,
});
expect(createStore().get(state.atom)).toEqual({ token: 'abc' });
});
it('falls back to the default when the persisted value fails validateInitFn', () => {
localStorage.setItem('invalidAtom', JSON.stringify({ nope: true }));
const state = createAtomState<Stored>({
key: 'invalidAtom',
defaultValue: null,
useLocalStorage: true,
localStorageOptions: { getOnInit: true },
validateInitFn,
});
expect(createStore().get(state.atom)).toBeNull();
});
});
@@ -24,12 +24,38 @@ type StateAtom<ValueType> = WritableAtom<
type LocalStorageOptions = { getOnInit?: boolean };
// Wraps the default JSON localStorage so a persisted value that fails
// validateInitFn falls back to the initial value instead of hydrating the atom
// with an invalid payload.
const createValidatedLocalStorage = <ValueType>(
validateInitFn: (payload: NonNullable<ValueType>) => boolean,
) => {
const storage = createJSONStorage<ValueType>(() => localStorage);
return {
...storage,
getItem: (key: string, initialValue: ValueType): ValueType => {
const value = storage.getItem(key, initialValue) as ValueType;
if (
isDefined(value) &&
!validateInitFn(value as NonNullable<ValueType>)
) {
return initialValue;
}
return value;
},
};
};
export const createAtomState = <ValueType>({
key,
defaultValue,
useLocalStorage = false,
useSessionStorage = false,
localStorageOptions,
validateInitFn,
useCookieStorage,
}: {
key: string;
@@ -37,6 +63,7 @@ export const createAtomState = <ValueType>({
useLocalStorage?: boolean;
useSessionStorage?: boolean;
localStorageOptions?: LocalStorageOptions;
validateInitFn?: (payload: NonNullable<ValueType>) => boolean;
useCookieStorage?: CookieStorageConfig<ValueType>;
}): State<ValueType> => {
let baseAtom: StateAtom<ValueType>;
@@ -59,10 +86,13 @@ export const createAtomState = <ValueType>({
getOnInit: true,
}) as StateAtom<ValueType>;
} else if (useLocalStorage) {
const storage = isDefined(validateInitFn)
? createValidatedLocalStorage<ValueType>(validateInitFn)
: undefined;
baseAtom = atomWithStorage<ValueType>(
key,
defaultValue,
undefined,
storage,
localStorageOptions ?? undefined,
) as StateAtom<ValueType>;
} else {