# Cookie-Shape Decision — Stage 1 (auth-A rewrite) **Date:** 2026-05-06 **Probe target:** `@supabase/ssr@0.6.1` + `@supabase/auth-js@2.101.1`, MovieDice self-hosted Supabase (GoTrue v2.170.x via Kong 2.8.1). **Cookie name in this project:** `sb-localhost-auth-token` (derived in `src/lib/supabase/cookie-name.ts`). --- ## 1. Empirical probe (current `signInAnonymously()` flow) `/tmp/probe-cookie.mjs` instantiates `createServerClient` with an in-memory cookie jar, calls `signInAnonymously()` against the live stack, and dumps everything `setAll` receives. **Result — exactly one cookie was set:** | Field | Value | | ------------ | --------------------------------------------------------------- | | name | `sb-localhost-auth-token` | | length | 1254 bytes (well under 3180 chunk threshold) | | value prefix | `base64-eyJhY2Nlc...` (literal `base64-` then base64url) | | options | `{ path:"/", sameSite:"lax", httpOnly:false, maxAge:34560000 }` | Stripping the `base64-` prefix and base64url-decoding yields a single JSON **object** (not an array): ```json { "access_token": "", "token_type": "bearer", "expires_in": 3600, "expires_at": 1778040370, "refresh_token": "Qhx2LTqz…", "user": { "id":"…", "aud":"authenticated", "role":"authenticated", … } } ``` No chunking observed in this run. Chunking kicks in only above 3180 URL-encoded bytes (see source-read below); a session with a fully populated `user` object stays comfortably below that. --- ## 2. Source-read confirmation Reading `node_modules/@supabase/ssr/dist/main/cookies.js`, `createServerClient.js`, `createBrowserClient.js`, `utils/chunker.js`, `utils/constants.js`: - **Default `cookieEncoding` is `"base64url"`** for both `createServerClient` (line 13) and `createBrowserClient` (line 21). When set, `setItem` writes `"base64-" + stringToBase64URL(value)`. The reader (`getItem`) strips `base64-` if present and base64url-decodes — otherwise returns raw — so the **read path is tolerant of either encoding**. - **`cookieEncoding: "raw"` is accepted** by both client constructors (any value other than `"base64url"` falls through the `if` and writes `value` verbatim). Clients reading a raw cookie also work because the prefix check is opt-in. - **Cookie name** = `cookieOptions.name` you pass; the storage key is set to the same value (see `createServerClient.js:25-27` and `createBrowserClient.js:34-36`). No `-auth-token` suffix is appended by `@supabase/ssr` — the project's `getSupabaseCookieName()` already bakes that in. - **What gets stored** (from `auth-js/GoTrueClient.js` `_saveSession` line 3938 + `helpers.js` `setItemAsync` line 124): `JSON.stringify(session)` where `session` is the full Session object including `user`. The validator `_isValidSession` (line 3748) only requires the keys `access_token`, `refresh_token`, `expires_at` to be **present** (`'k' in obj`); their values may be `null`/`""`. - **Chunking threshold** = `MAX_CHUNK_SIZE = 3180` (encodeURI'd-length, not raw). Names: `""` if it fits, else `".0"`, `".1"`, … (see `chunker.js:23-63`). The reader (`combineChunks`, line 66) tries the bare key first, then iterates `.0`, `.1`, … in order. - **`refresh_token` value tolerance** — verified empirically with `/tmp/probe-mint.mjs`: minted a HS256 token for an existing `auth.sessions` row, wrapped it as `base64-`, and called `getUser()` on a fresh `createServerClient`. Results: - `refresh_token: null` → ✅ user.id returned - `refresh_token: ""` → ✅ user.id returned - `refresh_token: "none"` → ✅ user.id returned - field omitted entirely → ❌ `Auth session missing!` (fails `_isValidSession`) --- ## 3. Decision: **Option (X) — match the default `base64url` shape** Stage 2 should write a single cookie of name `getSupabaseCookieName()` containing `"base64-" + base64url(JSON.stringify({access_token, token_type:"bearer", expires_in, expires_at, refresh_token:"", user:null}))`, with the same cookie options the SSR lib uses (`DEFAULT_COOKIE_OPTIONS` merged with our overrides). **Why X over Y/Z:** - `cookieEncoding: "raw"` (Y) is supported but means we'd diverge from what `signInAnonymously()` produces; a future SSR-lib change to default tokens (e.g., always strip "raw" mode, gate it behind a feature flag) would silently break us. Sticking with the default puts us on the well-trodden path. - We only ever set a single cookie. The chunker only matters if our payload exceeds 3180 URL-encoded bytes. With `user:null` and a typical HS256 access token (~700 bytes raw → ~950 base64url-encoded), our cookie is ~1100 bytes. **No chunking needed** for this design. (If `user` were ever populated we'd still be at ~1300 bytes — see § 1.) - `refresh_token: ""` is the safest sentinel: it's a string (so deepClone/JSON serializers don't trip), present (so `_isValidSession` passes), and length 0 (so `autoRefreshToken && currentSession.refresh_token` short-circuits — irrelevant on server clients, defensive on browser). ### `setSessionCookie` sketch (Stage 2 will copy into `src/lib/auth/cookies.ts`) ```ts import { cookies as nextCookies } from "next/headers"; import { getSupabaseCookieName } from "@/lib/supabase/cookie-name"; const COOKIE_MAX_AGE_S = 60 * 60 * 24 * 400; // matches @supabase/ssr default export async function setSessionCookie(accessToken: string, expiresAt: number) { const session = { access_token: accessToken, token_type: "bearer" as const, expires_in: Math.max(0, expiresAt - Math.floor(Date.now() / 1000)), expires_at: expiresAt, refresh_token: "", // present-but-empty: passes _isValidSession, blocks browser auto-refresh user: null, // _recoverAndRefresh handles null via userNotAvailableProxy + getUser() }; const json = JSON.stringify(session); const b64url = Buffer.from(json, "utf8") .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); (await nextCookies()).set(getSupabaseCookieName(), `base64-${b64url}`, { path: "/", sameSite: "lax", httpOnly: true, secure: true, maxAge: COOKIE_MAX_AGE_S, }); } ``` Note we deliberately set `httpOnly: true` (the SSR lib defaults to `false` because the browser client must read it via `document.cookie`; **but for our custom-mint flow the browser side comes via `createBrowserClient` against the same `localStorage`-equivalent path** — actually it doesn't, the browser still needs to read this. **Stage 2 must keep `httpOnly: false` to preserve browser-client readability.** Adjust the sketch accordingly when implementing.) ### Critical out-of-scope finding (FLAG TO PM) GoTrue v2.170 rejects any JWT whose `session_id` claim does not match an existing row in `auth.sessions`. Tested directly: minted JWT signed with `JWT_SECRET`, hit `/auth/v1/user` → `403 session_not_found`. Only succeeds when `session_id` references a real `auth.sessions.id`. **Implication for PLAN-AUTH-A:** the plan's "hand-mint short-lived access tokens" path (Decision: path-a) does **not** work end-to-end with GoTrue's `/user`, `/logout`, or any GoTrue-mediated endpoint unless we either (i) reuse a real `auth.sessions.id` (the same one created by the original `signInAnonymously()`), or (ii) accept that browser code calling `supabase.auth.getUser()` will route to GoTrue and fail. Browser code calling `getUser()` is exactly what `(app)` layouts and route handlers currently rely on. **Stage 2 cannot proceed until PM has been routed this finding** — recommend escalating to the PM agent before any cookie module is written. (Note: PostgREST + Realtime accept the JWT via HS256 signature alone, so RLS-gated reads/writes would work; only GoTrue endpoints fail. This may be acceptable if all `getUser()` callers are migrated to a custom `/api/auth/me` that decodes the JWT locally — but that is a plan-level decision.) --- ## 4. Verification recipe (for Stage 2's `src/__tests__/auth/cookies.test.ts`) ```ts // Pre-req: live GoTrue at NEXT_PUBLIC_SUPABASE_URL with JWT_SECRET in env. // Setup: signInAnonymously() once to create a real auth.sessions row, // then read its id from the DB (or extract from the response JWT). // (Cannot mock GoTrue: getUser() always re-validates against /auth/v1/user // using HS256 signature + session_id lookup.) it("setSessionCookie produces a cookie that getUser() accepts", async () => { const { uid, sessionId } = await createRealAuthSession(); // helper const exp = Math.floor(Date.now() / 1000) + 3600; const accessToken = await signHS256( { sub: uid, aud: "authenticated", role: "authenticated", session_id: sessionId, is_anonymous: true, iat: Math.floor(Date.now() / 1000), exp, email: "", phone: "", app_metadata: {}, user_metadata: {}, }, process.env.JWT_SECRET!, ); const jar = new Map(); // setSessionCookie writes via next/headers — in tests, swap for direct jar.set const sessionJson = JSON.stringify({ access_token: accessToken, token_type: "bearer", expires_in: 3600, expires_at: exp, refresh_token: "", user: null, }); jar.set(getSupabaseCookieName(), "base64-" + b64url(sessionJson)); const supabase = createServerClient(SUPABASE_URL, ANON_KEY, { cookieOptions: { name: getSupabaseCookieName() }, cookies: { getAll: () => [...jar.entries()].map(([name, value]) => ({ name, value })), setAll: () => {}, }, }); const { data, error } = await supabase.auth.getUser(); expect(error).toBeNull(); expect(data.user?.id).toBe(uid); }); ``` **Gotchas:** - `getUser()` always hits `${SUPABASE_URL}/auth/v1/user` (`auth-js/GoTrueClient.js:2463-2483`). Test must run against the live Docker stack, not a mock. - `session_id` claim is mandatory and must reference a real row in `auth.sessions` — helper `createRealAuthSession()` should call `signInAnonymously` and read the id back. - Do not pass `auth: { debug: true }` in CI — the noise hides assertion output. --- ## 5. GO/NO-GO **Stage 2 should implement option (X)** with shape `{access_token, token_type:"bearer", expires_in, expires_at, refresh_token:"", user:null}` written as `"base64-" + base64url(JSON.stringify(...))` to the cookie named by `getSupabaseCookieName()` — **verified empirically and against the v0.6.1 source** — **but is GATED on PM resolving the GoTrue `session_id`-claim finding in § 3 before writing any cookie code.**