COOKIE-SHAPE-DECISION.md 10 KB

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

{
  "access_token": "<eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…>",
  "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: "<key>" if it fits, else "<key>.0", "<key>.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-<json>, 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)

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/user403 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)

// 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<string, string>();
  // 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 sourcebut is GATED on PM resolving the GoTrue session_id-claim finding in § 3 before writing any cookie code.