PLAN-AUTH-A.md 31 KB

Implementation Plan: Approach A — Anonymous + Custom Recovery Layer (v3)

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.

Context

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:

  • Onboarding still uses signInAnonymously() (unchanged).
  • Recovery generate inserts argon2id(code) into a custom recovery_codes table.
  • Recovery claim verifies the code, atomically consumes it, mints a fresh JWT bound to the same sub, persists a user_sessions row for revocation, writes the @supabase/ssr cookie.
  • All users stay is_anonymous=true in auth.users. That flag becomes meaningless metadata.
  • auth.uid() resolves natively → all RLS policies unchanged.
  • Browser Realtime keeps working (HS256 JWTs signed with JWT_SECRET are accepted).
  • GoTrue config: EMAIL_ENABLED stays false. No Kong denylists needed.

Decisions settled (no longer open questions)

  • JWT lifecycle: path (a) — hand-mint short-lived access tokens, periodic re-mint via /api/auth/touch. Per security review #4 (v2 round).
  • Argon2 lookup: peppered HMAC prefix index from day one, not a follow-up.
  • Atomic claim: DELETE ... RETURNING is the commit point, not a separate scan-then-delete.
  • Session revocation: user_sessions table with revoked_at.
  • Absolute session cap: 30 days via iat_original JWT claim.
  • Trust boundary: local JWT decode + 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.
  • Browser uses useCurrentUser() hook that reads /api/auth/me (server-side requireUser()); browser never calls supabase.auth.getUser() directly.
  • CI grep-guard rejects raw supabase.auth.getUser() outside the current-user.ts wrapper module.

Scope

In scope

  1. DONE Stage 1 — Cookie-shape decision in 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}.
  2. Migration 00004_recovery_and_sessions.sql: drop users.recovery_code column; create recovery_codes (with peppered prefix index column); create user_sessions.
  3. Module 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.
  4. Module src/lib/auth/cookies.ts: write per Stage 1 Option (X).
  5. Module src/lib/auth/sessions.ts: create/lookup/revoke user_sessions rows.
  6. Module src/lib/auth/recovery-prefix.ts: HMAC-SHA256 with RECOVERY_CODE_PEPPER for the prefix index.
  7. NEW Module 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.
  8. NEW Route src/app/api/auth/me/route.ts: returns {id, isAnonymous: true} via requireUser(). Backs the browser hook.
  9. NEW Hook src/hooks/use-current-user.ts: TanStack Query against /api/auth/me with appropriate staleTime. Replaces ALL browser supabase.auth.getUser() / getSession() calls.
  10. Rewrite src/app/api/auth/recovery/generate/route.ts — uses requireUser() for auth.
  11. Rewrite src/app/api/auth/recovery/claim/route.ts — atomic DELETE...RETURNING, constant-time padding, mints JWT with generated session_id, inserts user_sessions row.
  12. NEW src/app/api/auth/touch/route.ts with absolute-cap enforcement (uses requireUser() then re-mints).
  13. Migrate src/middleware.ts (line 33): replace supabase.auth.getUser() with requireUser(req).
  14. Migrate 14 API route handlers: replace inline supabase.auth.getUser() with requireUser(req). Files identified by grep -rn "auth.getUser\|auth.getSession" src/app/api/. Programmer must list and migrate each.
  15. Migrate 5 browser hooks: 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().
  16. Migrate src/app/(auth)/recovery/page.tsx: replace supabase.auth.getSession() with useCurrentUser().
  17. Update src/lib/supabase/client.ts: auth: { autoRefreshToken: false, persistSession: true, detectSessionInUrl: false }. Cookie encoding stays default (Stage 1 Option X).
  18. Layout-level token-age check + touch-failure handler: in (app) layout. On touch 401: queryClient.clear() + hard-redirect to /.
  19. Update src/lib/supabase/admin.ts: drop the EMAIL_ENABLED !== false assertion (rationale obsolete).
  20. Delete src/lib/auth/synthetic.ts (after grep-verify zero importers post-rewrites).
  21. Tests: delete synthetic.test.ts, promotion-regression.test.ts. Rewrite recovery-generate.test.ts, recovery-claim.test.ts. Add per "Tests" section below.
  22. Update src/types/database.ts: drop users.recovery_code; add recovery_codes, user_sessions.
  23. Update src/env.ts: ensure JWT_SECRET and add RECOVERY_CODE_PEPPER in the server schema (never client).
  24. docker-compose.yml: verify JWT_SECRET is exposed to the Next.js app container; add RECOVERY_CODE_PEPPER (32+ random chars).
  25. Sentry beforeSend: drop events whose stringified payload contains JWT_SECRET or RECOVERY_CODE_PEPPER literal value.
  26. CI grep-guard test: fail if 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.
  27. Update 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.

Out of scope

  • Removing GoTrue from docker-compose. signInAnonymously() is fine to keep delegated.
  • Approach B (custom JWT, no GoTrue for users) — landscape-only.
  • Migration of existing users.recovery_code data (dev-only per project state).
  • Admin TOTP path (untouched).
  • CSP script-src hardening (security #10) — flagged as separate follow-up.

Detailed steps

Step 0: Cookie-shape probe (blocking)

Before writing any code: run a one-shot script in dev to capture what signInAnonymously() writes today. This determines whether setSessionCookie writes:

  • (X) base64- prefix + base64url(JSON.stringify(session-object))
  • (Y) raw JSON array [access, refresh, ...]
  • (Z) chunked ${name}.0, ${name}.1 for large values

Decision tree:

  • If (X): match it exactly using stringToBase64URL from @supabase/ssr internals OR construct both clients with cookieOptions: { cookieEncoding: "raw" } and use raw JSON object.
  • If (Z) for our token size (~1.5 KB): must implement chunking OR force 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.

Step 1: Migration 00004_recovery_and_sessions.sql

Code-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.

Step 2: src/lib/auth/jwt.ts

import { 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.

Step 3: src/lib/auth/recovery-prefix.ts

import { 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.

Step 4: src/lib/auth/sessions.ts

import { 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);
}

Step 5: src/lib/auth/cookies.ts

Final 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);
}

Step 6: Rewrite src/app/api/auth/recovery/generate/route.ts

import { 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 });
}

Step 7: Rewrite src/app/api/auth/recovery/claim/route.ts

import { 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));
}

Step 8: src/app/api/auth/touch/route.ts

import { 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 });
}

Step 9: Browser client adjustments

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.

Step 10: Layout-level token-age check + touch-failure handler

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
    }
  }
}

Step 11: src/lib/supabase/admin.ts assertion

Currently 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.

Step 12: Sentry beforeSend filter

src/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.

Step 13: Delete src/lib/auth/synthetic.ts

Pre-step: grep -rn "from \"@/lib/auth/synthetic\"" src/ and grep -rn "synthetic" src/ to confirm zero importers (after the route rewrites). Delete the file.

Step 14: Test updates (in scope, NOT follow-up)

  • Delete src/__tests__/auth/synthetic.test.ts, src/__tests__/auth/promotion-regression.test.ts.
  • Rewrite src/__tests__/api/recovery-generate.test.ts:
    • 401 unauthorized, 429 rate-limited, 409 already-exists, 200 happy path (verifies row inserted with both argon2_hash and prefix_hmac).
  • Rewrite src/__tests__/api/recovery-claim.test.ts:
    • 400 missing code, 429 rate-limited, 401 invalid (with constant-time padding), 200 happy path (cookie set, row deleted, session row created).
    • Concurrency test: two parallel claims of the same code — exactly one returns 200.
  • New src/__tests__/auth/jwt.test.ts: mint/verify roundtrip; kid/iss/aud correct; expired/nbf rejected; iat_original preserved across touches.
  • New 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.
  • New src/__tests__/api/touch.test.ts: 401 missing/invalid token, 401 absolute-cap exceeded, 401 revoked session, 200 happy path with iat_original preserved.
  • New 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.
  • New 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."
  • New 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).

Step 15: Update CLAUDE.md

Replace the entire "Auth → Recovery" section with:

Recovery code is stored Argon2id-hashed in public.recovery_codes with an HMAC-prefix index (peppered with RECOVERY_CODE_PEPPER) for O(1) lookup. Generate is server-side, rate-limited per uid, idempotent (409 if already present). Claim atomically deletes via DELETE ... RETURNING, mints a fresh HS256 JWT bound to the same sub (signed with JWT_SECRET, kid: "v1", iss: "moviedice", aud: "authenticated", nbf: -10s), creates a user_sessions row, writes the @supabase/ssr cookie. Sessions have a 1h access TTL with re-mint via POST /api/auth/touch, capped absolutely at 30 days from the original iat_original. Revocation: flip user_sessions.revoked_at; touch refuses to re-mint. All users remain is_anonymous=true in auth.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.

Critical files

New:

  • supabase/migrations/00004_recovery_and_sessions.sql
  • src/lib/auth/jwt.ts
  • src/lib/auth/cookies.ts
  • src/lib/auth/sessions.ts
  • src/lib/auth/recovery-prefix.ts
  • src/app/api/auth/touch/route.ts
  • Tests per Step 14

Rewrite:

  • src/app/api/auth/recovery/generate/route.ts
  • src/app/api/auth/recovery/claim/route.ts

Modify:

  • 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 handler
  • CLAUDE.md

Delete:

  • src/lib/auth/synthetic.ts
  • src/__tests__/auth/synthetic.test.ts
  • src/__tests__/auth/promotion-regression.test.ts

Dependencies

  • jose — verify with npm ls jose. Likely already present (admin TOTP path may use it). Install if missing.
  • No other new deps.

Verification

  1. npm run build clean.
  2. tsc --noEmit clean (catches Compliance #2: synthetic test files importing dead modules).
  3. npm test — all unit + integration suites pass.
  4. Manual probe at http://localhost:3000:
    • Fresh incognito → anon signin → home renders.
    • /recovery → code displayed.
    • Second incognito → /recover → paste code → hard-nav to / → original user's session restored.
    • Re-claim same code → 401 (single-use enforced).
    • Wait >1h or force-expire token → trigger touch → cookie re-minted, no UI break.
    • Run UPDATE user_sessions SET revoked_at = now() WHERE user_id = ... → trigger touch → 401, hard-clear, redirect.
  5. SQL spot-check: SELECT count(*) FROM recovery_codes = 0 after a successful claim. SELECT * FROM user_sessions WHERE user_id = ... shows the session.
  6. RLS spot-check: log in as recovered user, SELECT auth.uid() returns original UID.
  7. Realtime smoke: subscribe to movies channel from browser console after recovery — confirm WebSocket auth succeeds.

Rollback

  • Migration 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.
  • For dev: psql -c "ALTER TABLE public.users ADD COLUMN recovery_code text; DROP TABLE public.recovery_codes; DROP TABLE public.user_sessions;" if needed.

Follow-ups (post-merge)

  • PM agent updates PROJECT_SCOPE.md to reflect the new recovery model.
  • CSP script-src hardening review (security #10) — separate work.
  • Audit getClientIp "unknown" fallback — global rate-limit bucket collision (compliance #10) — separate work.
  • If JWT_SECRET ever needs rotation: leverage kid: "v1" to dual-mint during transition.