Status: Revised after Stage 1 cookie probe surfaced a blocker (GoTrue rejects HS256 JWTs whose session_id is not in auth.sessions). Both compliance and security agents recommended Option 3: replace every supabase.auth.getUser() call with a local JWT-decode wrapper. v3 bakes that decision in. See research/COOKIE-SHAPE-DECISION.md for the Stage 1 finding and research/AUTH-APPROACHES.md for the original landscape.
Current recovery design (synthetic <uid>@moviedice.invalid email + HKDF password + GoTrue admin.updateUserById "promotion") is structurally fighting GoTrue. It hit bug #2013, required a CHECK constraint that broke the flow, and security review demanded Kong-level denylists once EMAIL_ENABLED=true. The product never needed permanent-account "promotion" — only "given a valid recovery code, give the same user a fresh session." Approach A delivers exactly that.
Outcome:
signInAnonymously() (unchanged).argon2id(code) into a custom recovery_codes table.sub, persists a user_sessions row for revocation, writes the @supabase/ssr cookie.is_anonymous=true in auth.users. That flag becomes meaningless metadata.auth.uid() resolves natively → all RLS policies unchanged.JWT_SECRET are accepted).EMAIL_ENABLED stays false. No Kong denylists needed./api/auth/touch. Per security review #4 (v2 round).DELETE ... RETURNING is the commit point, not a separate scan-then-delete.user_sessions table with revoked_at.iat_original JWT claim.user_sessions lookup, NOT supabase.auth.getUser() (NEW v3 decision). Per Stage 1: GoTrue rejects HS256 JWTs whose session_id is not in auth.sessions. Reusing/inserting that row is a forgery + GC + coupling hazard. Instead, replace every getUser() call with a thin server wrapper requireUser() that does local jwtVerify + user_sessions.revoked_at IS NULL check. PostgREST + Realtime continue to accept the JWT via signature alone — only GoTrue's /auth/v1/user rejects, and after Option 3 nothing in the app calls it.useCurrentUser() hook that reads /api/auth/me (server-side requireUser()); browser never calls supabase.auth.getUser() directly.supabase.auth.getUser() outside the current-user.ts wrapper module.research/COOKIE-SHAPE-DECISION.md. Option (X): base64- + base64url(JSON object) with {access_token, token_type:"bearer", expires_in, expires_at, refresh_token:"", user:null}.00004_recovery_and_sessions.sql: drop users.recovery_code column; create recovery_codes (with peppered prefix index column); create user_sessions.src/lib/auth/jwt.ts: HS256 mint + verify with kid: "v1", iat_original claim, setNotBefore(-10s), setIssuer("moviedice"), setAudience("authenticated"). session_id is generated UUID, persisted in user_sessions. Does NOT need to satisfy GoTrue's getUser() — see v3 trust-boundary decision.src/lib/auth/cookies.ts: write per Stage 1 Option (X).src/lib/auth/sessions.ts: create/lookup/revoke user_sessions rows.src/lib/auth/recovery-prefix.ts: HMAC-SHA256 with RECOVERY_CODE_PEPPER for the prefix index.src/lib/auth/current-user.ts (Option 3 wrapper): requireUser(req) does local verifyAccessToken + user_sessions.revoked_at IS NULL check + 30d cap. getCurrentUser(req) non-throwing variant. The ONLY place in the codebase that decodes the JWT or queries user_sessions. Enforced by CI grep-guard.src/app/api/auth/me/route.ts: returns {id, isAnonymous: true} via requireUser(). Backs the browser hook.src/hooks/use-current-user.ts: TanStack Query against /api/auth/me with appropriate staleTime. Replaces ALL browser supabase.auth.getUser() / getSession() calls.src/app/api/auth/recovery/generate/route.ts — uses requireUser() for auth.src/app/api/auth/recovery/claim/route.ts — atomic DELETE...RETURNING, constant-time padding, mints JWT with generated session_id, inserts user_sessions row.src/app/api/auth/touch/route.ts with absolute-cap enforcement (uses requireUser() then re-mints).src/middleware.ts (line 33): replace supabase.auth.getUser() with requireUser(req).supabase.auth.getUser() with requireUser(req). Files identified by grep -rn "auth.getUser\|auth.getSession" src/app/api/. Programmer must list and migrate each.use-realtime-movies, use-add-movie, use-delete-movie, use-toggle-watched, use-all-user-movies, use-user-groups. Replace inline supabase.auth.getUser() with useCurrentUser().src/app/(auth)/recovery/page.tsx: replace supabase.auth.getSession() with useCurrentUser().src/lib/supabase/client.ts: auth: { autoRefreshToken: false, persistSession: true, detectSessionInUrl: false }. Cookie encoding stays default (Stage 1 Option X).(app) layout. On touch 401: queryClient.clear() + hard-redirect to /.src/lib/supabase/admin.ts: drop the EMAIL_ENABLED !== false assertion (rationale obsolete).src/lib/auth/synthetic.ts (after grep-verify zero importers post-rewrites).synthetic.test.ts, promotion-regression.test.ts. Rewrite recovery-generate.test.ts, recovery-claim.test.ts. Add per "Tests" section below.src/types/database.ts: drop users.recovery_code; add recovery_codes, user_sessions.src/env.ts: ensure JWT_SECRET and add RECOVERY_CODE_PEPPER in the server schema (never client).JWT_SECRET is exposed to the Next.js app container; add RECOVERY_CODE_PEPPER (32+ random chars).beforeSend: drop events whose stringified payload contains JWT_SECRET or RECOVERY_CODE_PEPPER literal value.supabase.auth.getUser() or supabase.auth.getSession() appears anywhere in src/ except src/lib/auth/current-user.ts and src/hooks/use-current-user.ts. Fail if auth.jwt()->>'is_anonymous' appears in any new migration.CLAUDE.md: replace synthetic-identity / GoTrue #2013 / EMAIL_ENABLED / Kong denylist paragraphs. Document requireUser() / useCurrentUser() as the only auth boundary. Note is_anonymous is meaningless and getUser() is forbidden outside the wrapper.signInAnonymously() is fine to keep delegated.users.recovery_code data (dev-only per project state).script-src hardening (security #10) — flagged as separate follow-up.Before writing any code: run a one-shot script in dev to capture what signInAnonymously() writes today. This determines whether setSessionCookie writes:
base64- prefix + base64url(JSON.stringify(session-object))[access, refresh, ...]${name}.0, ${name}.1 for large valuesDecision tree:
stringToBase64URL from @supabase/ssr internals OR construct both clients with cookieOptions: { cookieEncoding: "raw" } and use raw JSON object.raw encoding.Recommended path: pass cookieOptions: { cookieEncoding: "raw" } to BOTH createBrowserClient and createServerClient (they accept it per node_modules/@supabase/ssr/dist/main/createServerClient.js:13). Then write a plain JSON object matching the modern Supabase Session shape: { access_token, refresh_token, expires_at, expires_in, token_type, user }. For path (a), refresh_token is a non-empty placeholder (a constant like "NO_REFRESH") since the browser client has autoRefreshToken: false and won't try to use it. Verify this doesn't trip getUser() validation.
Acceptance: a Vitest integration test that calls setSessionCookie(mintedToken) then constructs a createServerClient against the same cookie store and asserts getUser() returns the expected sub. Cannot ship without this test passing — security #8 / compliance #1.
00004_recovery_and_sessions.sqlCode-first deploy ordering: this migration runs AFTER the new app code is deployed and BEFORE the old users.recovery_code column reads are removed. (Per compliance #4.) Concretely: deploy app code that tolerates either schema first, then run migration, then remove tolerance. For dev (no production yet), simpler: stop dev server, apply migration, deploy code, restart.
-- 00004_recovery_and_sessions.sql
ALTER TABLE public.users DROP COLUMN IF EXISTS recovery_code;
CREATE TABLE public.recovery_codes (
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
argon2_hash text NOT NULL,
prefix_hmac bytea NOT NULL, -- HMAC-SHA256(pepper, code) truncated to first 8 bytes
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_recovery_codes_prefix ON public.recovery_codes(prefix_hmac);
ALTER TABLE public.recovery_codes ENABLE ROW LEVEL SECURITY;
-- No policies; service role only.
COMMENT ON TABLE public.recovery_codes IS
'Argon2id-hashed recovery codes with HMAC prefix index. Service role only. Single-use; deleted on claim atomically via DELETE ... RETURNING.';
CREATE TABLE public.user_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
iat_original timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz
);
CREATE INDEX idx_user_sessions_user_active ON public.user_sessions(user_id) WHERE revoked_at IS NULL;
ALTER TABLE public.user_sessions ENABLE ROW LEVEL SECURITY;
-- No policies; service role only.
COMMENT ON TABLE public.user_sessions IS
'Server-side session records. JWT carries session_id; touch endpoint validates revoked_at IS NULL and now() - iat_original < 30d.';
Note on is_anonymous: under this design auth.jwt()->>''is_anonymous'' returns true for every real user. grep -rn "is_anonymous" supabase/migrations/ to verify no policy depends on it. CLAUDE.md gets a warning line.
src/lib/auth/jwt.tsimport { SignJWT, jwtVerify } from "jose";
const ACCESS_EXP_SECONDS = 60 * 60; // 1h
const ABSOLUTE_CAP_SECONDS = 30 * 24 * 60 * 60; // 30d
const NBF_SKEW_SECONDS = 10;
interface AccessClaims {
sub: string;
session_id: string;
iat_original: number; // unix seconds, set on first mint, preserved across touches
}
export async function mintAccessToken(claims: AccessClaims): Promise<string> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
return await new SignJWT({
sub: claims.sub,
role: "authenticated",
is_anonymous: true,
session_id: claims.session_id,
iat_original: claims.iat_original,
})
.setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
.setIssuedAt()
.setNotBefore(`${-NBF_SKEW_SECONDS}s`)
.setExpirationTime(`${ACCESS_EXP_SECONDS}s`)
.setIssuer("moviedice")
.setAudience("authenticated")
.sign(secret);
}
export async function verifyAccessToken(token: string): Promise<AccessClaims | null> {
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload } = await jwtVerify(token, secret, {
issuer: "moviedice",
audience: "authenticated",
});
return {
sub: payload.sub as string,
session_id: payload.session_id as string,
iat_original: payload.iat_original as number,
};
} catch {
return null;
}
}
export const ACCESS_TOKEN_TTL_SECONDS = ACCESS_EXP_SECONDS;
export const ABSOLUTE_SESSION_CAP_SECONDS = ABSOLUTE_CAP_SECONDS;
session_id is generated at claim time and persisted (Step 4); not a per-mint random.
src/lib/auth/recovery-prefix.tsimport { createHmac } from "node:crypto";
export function prefixHmac(code: string): Buffer {
const pepper = process.env.RECOVERY_CODE_PEPPER;
if (!pepper) throw new Error("RECOVERY_CODE_PEPPER not set");
return createHmac("sha256", pepper).update(code).digest().subarray(0, 8);
}
Pepper lives in env (32+ random chars), never in code, never client-exposed.
src/lib/auth/sessions.tsimport { getSupabaseAdminClient } from "@/lib/supabase/admin";
import { ABSOLUTE_SESSION_CAP_SECONDS } from "@/lib/auth/jwt";
export async function createSession(userId: string): Promise<{ id: string; iat_original: number }> {
const admin = getSupabaseAdminClient();
const { data, error } = await admin
.from("user_sessions")
.insert({ user_id: userId })
.select("id, iat_original")
.single();
if (error || !data) throw new Error("Failed to create session");
return { id: data.id, iat_original: Math.floor(new Date(data.iat_original).getTime() / 1000) };
}
export async function isSessionLive(sessionId: string, iatOriginal: number): Promise<boolean> {
const admin = getSupabaseAdminClient();
const { data } = await admin
.from("user_sessions")
.select("revoked_at")
.eq("id", sessionId)
.maybeSingle();
if (!data || data.revoked_at) return false;
if (Math.floor(Date.now() / 1000) - iatOriginal > ABSOLUTE_SESSION_CAP_SECONDS) return false;
// touch last_seen_at (fire-and-forget)
void admin
.from("user_sessions")
.update({ last_seen_at: new Date().toISOString() })
.eq("id", sessionId);
return true;
}
export async function revokeSession(sessionId: string): Promise<void> {
const admin = getSupabaseAdminClient();
await admin
.from("user_sessions")
.update({ revoked_at: new Date().toISOString() })
.eq("id", sessionId);
}
src/lib/auth/cookies.tsFinal shape determined in Step 0. Sketched assuming cookieEncoding: "raw" works:
import { cookies } from "next/headers";
import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
import { ACCESS_TOKEN_TTL_SECONDS } from "@/lib/auth/jwt";
export async function setSessionCookie(accessToken: string): Promise<void> {
const name = `${getSupabaseCookieName()}-auth-token`;
const expiresAt = Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL_SECONDS;
const session = {
access_token: accessToken,
refresh_token: "NO_REFRESH", // placeholder; client has autoRefreshToken: false
expires_at: expiresAt,
expires_in: ACCESS_TOKEN_TTL_SECONDS,
token_type: "bearer",
user: null, // verify in Step 0 probe whether this can be null
};
(await cookies()).set(name, JSON.stringify(session), {
httpOnly: false, // @supabase/ssr browser client must read it; matches GoTrue's cookie posture
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: ACCESS_TOKEN_TTL_SECONDS,
});
}
export async function clearSessionCookie(): Promise<void> {
const name = `${getSupabaseCookieName()}-auth-token`;
(await cookies()).delete(name);
}
src/app/api/auth/recovery/generate/route.tsimport { NextRequest, NextResponse } from "next/server";
import { getSupabaseServerClient } from "@/lib/supabase/server";
import { getSupabaseAdminClient } from "@/lib/supabase/admin";
import { generateRecoveryCode, hashRecoveryCode } from "@/lib/auth/recovery";
import { prefixHmac } from "@/lib/auth/recovery-prefix";
import { checkRateLimit } from "@/lib/auth/rate-limit";
const PER_UID_WINDOW_MS = 60 * 60 * 1000;
const PER_UID_MAX = 3;
export async function POST(request: NextRequest) {
const authHeader = request.headers.get("authorization");
const bearerToken = authHeader?.toLowerCase().startsWith("bearer ")
? authHeader.slice(7).trim()
: null;
const admin = getSupabaseAdminClient();
let uid: string | null = null;
let authPath: "bearer" | "cookie" | null = null;
if (bearerToken) {
const { data, error } = await admin.auth.getUser(bearerToken);
if (!error && data.user) {
uid = data.user.id;
authPath = "bearer";
}
}
if (!uid) {
const supabase = await getSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (user) {
uid = user.id;
authPath = "cookie";
}
}
if (!uid) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const limit = checkRateLimit(`recovery-generate:${uid}`, {
windowMs: PER_UID_WINDOW_MS,
maxAttempts: PER_UID_MAX,
});
if (!limit.allowed) {
return NextResponse.json(
{ error: "Too many attempts." },
{
status: 429,
headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) },
},
);
}
const { data: existing } = await admin
.from("recovery_codes")
.select("user_id")
.eq("user_id", uid)
.maybeSingle();
if (existing) {
return NextResponse.json({ error: "Recovery code already exists" }, { status: 409 });
}
const code = generateRecoveryCode();
const argonHash = await hashRecoveryCode(code);
const prefix = prefixHmac(code);
const { error } = await admin
.from("recovery_codes")
.insert({ user_id: uid, argon2_hash: argonHash, prefix_hmac: prefix });
if (error) {
return NextResponse.json({ error: "Failed to store recovery code" }, { status: 500 });
}
console.log(`[recovery/generate] uid=${uid} authPath=${authPath}`);
return NextResponse.json({ code });
}
src/app/api/auth/recovery/claim/route.tsimport { NextRequest, NextResponse } from "next/server";
import { getSupabaseAdminClient } from "@/lib/supabase/admin";
import { verifyRecoveryCode } from "@/lib/auth/recovery";
import { prefixHmac } from "@/lib/auth/recovery-prefix";
import { checkRateLimit, getClientIp } from "@/lib/auth/rate-limit";
import { mintAccessToken } from "@/lib/auth/jwt";
import { createSession } from "@/lib/auth/sessions";
import { setSessionCookie } from "@/lib/auth/cookies";
const IP_WINDOW_MS = 15 * 60 * 1000;
const IP_MAX = 5;
const TARGET_RESPONSE_MS = 200; // pad to defeat timing channel
export async function POST(request: NextRequest) {
const start = Date.now();
const ip = getClientIp(request);
const limit = checkRateLimit(`recovery-claim:${ip}`, {
windowMs: IP_WINDOW_MS,
maxAttempts: IP_MAX,
});
if (!limit.allowed) {
return NextResponse.json(
{ error: "Too many attempts." },
{
status: 429,
headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) },
},
);
}
const body = await request.json().catch(() => null);
const code = typeof body?.code === "string" ? body.code.trim() : null;
if (!code) return NextResponse.json({ error: "Invalid code" }, { status: 400 });
const admin = getSupabaseAdminClient();
const prefix = prefixHmac(code);
// Prefix-narrowed lookup (typically 0-1 rows; collision possible but negligible at 8-byte HMAC).
const { data: candidates } = await admin
.from("recovery_codes")
.select("user_id, argon2_hash")
.eq("prefix_hmac", prefix);
let matchedUid: string | null = null;
for (const row of candidates ?? []) {
if (await verifyRecoveryCode(code, row.argon2_hash)) {
matchedUid = row.user_id;
break;
}
}
if (!matchedUid) {
await padResponse(start);
return NextResponse.json({ error: "Invalid recovery code" }, { status: 401 });
}
// Atomic single-use: DELETE ... RETURNING. If rowCount === 0, someone else won the race.
const { data: deleted, error: deleteError } = await admin
.from("recovery_codes")
.delete()
.eq("user_id", matchedUid)
.select("user_id");
if (deleteError || !deleted || deleted.length === 0) {
await padResponse(start);
return NextResponse.json({ error: "Invalid recovery code" }, { status: 401 });
}
const session = await createSession(matchedUid);
const access = await mintAccessToken({
sub: matchedUid,
session_id: session.id,
iat_original: session.iat_original,
});
await setSessionCookie(access);
await padResponse(start);
return NextResponse.json({ ok: true });
}
async function padResponse(startMs: number): Promise<void> {
const elapsed = Date.now() - startMs;
const remaining = TARGET_RESPONSE_MS - elapsed;
if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));
}
src/app/api/auth/touch/route.tsimport { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
import { mintAccessToken, verifyAccessToken, ABSOLUTE_SESSION_CAP_SECONDS } from "@/lib/auth/jwt";
import { isSessionLive } from "@/lib/auth/sessions";
import { setSessionCookie } from "@/lib/auth/cookies";
export async function POST(request: NextRequest) {
// Origin check (CSRF). Per security #16.
const origin = request.headers.get("origin");
const expectedOrigin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
if (origin && origin !== expectedOrigin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const cookieStore = await cookies();
const cookieName = `${getSupabaseCookieName()}-auth-token`;
const raw = cookieStore.get(cookieName)?.value;
if (!raw) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
let accessToken: string;
try {
const session = JSON.parse(raw);
accessToken = session.access_token;
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const claims = await verifyAccessToken(accessToken);
if (!claims) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// Absolute cap: refuse re-mint if iat_original is older than 30 days.
if (Math.floor(Date.now() / 1000) - claims.iat_original > ABSOLUTE_SESSION_CAP_SECONDS) {
return NextResponse.json({ error: "Session expired" }, { status: 401 });
}
// Session-table check: revoked? still within cap?
if (!(await isSessionLive(claims.session_id, claims.iat_original))) {
return NextResponse.json({ error: "Session revoked" }, { status: 401 });
}
const fresh = await mintAccessToken({
sub: claims.sub,
session_id: claims.session_id,
iat_original: claims.iat_original, // preserved across touches
});
await setSessionCookie(fresh);
return NextResponse.json({ ok: true });
}
src/lib/supabase/client.ts:
createBrowserClient(url, key, {
cookieOptions: { name: getSupabaseCookieName(), cookieEncoding: "raw" }, // pin encoding
auth: { autoRefreshToken: false, persistSession: true, detectSessionInUrl: false },
});
Same cookieEncoding: "raw" on createServerClient everywhere it's constructed.
In the (app) layout (or a small client component mounted in it), on mount and on a 5-min interval:
const session = await supabase.auth.getSession();
if (session.data.session) {
const expiresAt = session.data.session.expires_at ?? 0;
if (expiresAt - Math.floor(Date.now() / 1000) < 5 * 60) {
const res = await fetch("/api/auth/touch", { method: "POST" });
if (!res.ok) {
queryClient.clear(); // hard-clear TanStack cache (security #14)
window.location.assign("/"); // redirect to landing
}
}
}
src/lib/supabase/admin.ts assertionCurrently asserts GOTRUE_EXTERNAL_EMAIL_ENABLED !== "false" with rationale "synthetic-recovery requires admin-API-only email identity creation." That rationale is now obsolete. Drop the assertion entirely — the new design has no constraint on this flag. Update CLAUDE.md accordingly.
beforeSend filtersrc/sentry.config.ts (or wherever beforeSend lives): in addition to current UUID stripping, drop any event whose JSON-stringified payload contains the literal value of JWT_SECRET or RECOVERY_CODE_PEPPER. Compare in constant time.
src/lib/auth/synthetic.tsPre-step: grep -rn "from \"@/lib/auth/synthetic\"" src/ and grep -rn "synthetic" src/ to confirm zero importers (after the route rewrites). Delete the file.
src/__tests__/auth/synthetic.test.ts, src/__tests__/auth/promotion-regression.test.ts.src/__tests__/api/recovery-generate.test.ts:
argon2_hash and prefix_hmac).src/__tests__/api/recovery-claim.test.ts:
src/__tests__/auth/jwt.test.ts: mint/verify roundtrip; kid/iss/aud correct; expired/nbf rejected; iat_original preserved across touches.src/__tests__/auth/cookies.test.ts: write cookie via setSessionCookie, read via createServerClient instance, assert getUser() returns expected sub. Catches the cookie-shape blocker (security #8 / compliance #1) directly.src/__tests__/api/touch.test.ts: 401 missing/invalid token, 401 absolute-cap exceeded, 401 revoked session, 200 happy path with iat_original preserved.src/__tests__/integration/recovery-flow.test.ts (gated on RUN_INTEGRATION=1): generate → claim → cookie set → auth.uid() matches → touch → revoke → touch fails. Real GoTrue + Postgres.src/__tests__/integration/realtime-smoke.test.ts (also gated): minted JWT can subscribe to a movies channel — validates the load-bearing claim that "Realtime keeps working."src/__tests__/api/recovery-ci.test.ts: grep-style CI guard that auth.jwt()->>'is_anonymous' does not appear in any new migration (security #9).CLAUDE.mdReplace the entire "Auth → Recovery" section with:
Recovery code is stored Argon2id-hashed in
public.recovery_codeswith an HMAC-prefix index (peppered withRECOVERY_CODE_PEPPER) for O(1) lookup. Generate is server-side, rate-limited per uid, idempotent (409 if already present). Claim atomically deletes viaDELETE ... RETURNING, mints a fresh HS256 JWT bound to the samesub(signed withJWT_SECRET,kid: "v1",iss: "moviedice",aud: "authenticated",nbf: -10s), creates auser_sessionsrow, writes the@supabase/ssrcookie. Sessions have a 1h access TTL with re-mint viaPOST /api/auth/touch, capped absolutely at 30 days from the originaliat_original. Revocation: flipuser_sessions.revoked_at; touch refuses to re-mint. All users remainis_anonymous=trueinauth.users— that flag has NO semantic meaning under this design; do not gate any policy or UI on it.
Remove all "Synthetic-identity-at-generate", 00003_synthetic_email_constraint, GoTrue #2013 workaround, and Kong-denylist lines.
New:
supabase/migrations/00004_recovery_and_sessions.sqlsrc/lib/auth/jwt.tssrc/lib/auth/cookies.tssrc/lib/auth/sessions.tssrc/lib/auth/recovery-prefix.tssrc/app/api/auth/touch/route.tsRewrite:
src/app/api/auth/recovery/generate/route.tssrc/app/api/auth/recovery/claim/route.tsModify:
src/lib/supabase/client.ts (add autoRefreshToken: false, cookieEncoding: "raw")src/lib/supabase/server.ts (verify cookieEncoding: "raw" if needed)src/lib/supabase/admin.ts (drop EMAIL_ENABLED assertion)src/types/database.ts (drop users.recovery_code, add new tables)src/env.ts (ensure JWT_SECRET and add RECOVERY_CODE_PEPPER, both server-only)src/sentry.config.ts (or wherever beforeSend lives — secret-leak filter)docker-compose.yml (verify JWT_SECRET exposed to app container; add RECOVERY_CODE_PEPPER)(app) layout — token-age check + touch-failure handlerCLAUDE.mdDelete:
src/lib/auth/synthetic.tssrc/__tests__/auth/synthetic.test.tssrc/__tests__/auth/promotion-regression.test.tsjose — verify with npm ls jose. Likely already present (admin TOTP path may use it). Install if missing.npm run build clean.tsc --noEmit clean (catches Compliance #2: synthetic test files importing dead modules).npm test — all unit + integration suites pass.http://localhost:3000:
/recovery → code displayed./recover → paste code → hard-nav to / → original user's session restored.UPDATE user_sessions SET revoked_at = now() WHERE user_id = ... → trigger touch → 401, hard-clear, redirect.SELECT count(*) FROM recovery_codes = 0 after a successful claim. SELECT * FROM user_sessions WHERE user_id = ... shows the session.SELECT auth.uid() returns original UID.movies channel from browser console after recovery — confirm WebSocket auth succeeds.00004 is forward-only. If app code regresses post-deploy, redeploy old code; the new tables sit unused. Do NOT roll back the migration unless the new app code is fully out of production — old code reads users.recovery_code which is gone.psql -c "ALTER TABLE public.users ADD COLUMN recovery_code text; DROP TABLE public.recovery_codes; DROP TABLE public.user_sessions;" if needed.PROJECT_SCOPE.md to reflect the new recovery model.script-src hardening review (security #10) — separate work.getClientIp "unknown" fallback — global rate-limit bucket collision (compliance #10) — separate work.JWT_SECRET ever needs rotation: leverage kid: "v1" to dual-mint during transition.