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).
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.
Reading node_modules/@supabase/ssr/dist/main/cookies.js, createServerClient.js, createBrowserClient.js, utils/chunker.js, utils/constants.js:
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.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.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/"".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 returnedrefresh_token: "" → ✅ user.id returnedrefresh_token: "none" → ✅ user.id returnedAuth session missing! (fails _isValidSession)base64url shapeStage 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.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.)
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.)
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.auth: { debug: true } in CI — the noise hides assertion output.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.