Project One

Authentication

Member sign-in (password primary, magic-link fallback), sign-out, and the magic-link landing. These are redirect surfaces, not JSON APIs — every outcome (except a 429) is a 303 See Other. Programmatic callers should send requests with redirects disabled and read the Location header.

Endpoint Auth Rate limit
POST /api/auth/login public (mints the session) 5/5 min per IP
POST /api/auth/logout none (destroys any session) none
GET /auth/confirm the one-time token_hash is the credential none

Accounts are only created by onboarding — the login flows never mint users (shouldCreateUser: false).

Session cookie attributes (WSTG-SESS-02): all sb-* cookies are set HttpOnly; SameSite=Lax, plus Secure in production — uniformly across the login route, server components, and the middleware refresh.


POST /api/auth/login

JSON or form-encoded: email (required, format-validated), password (required in password mode), mode ("link" selects magic-link; any other value — including absent or "Link" — silently means password mode).

Outcome vocabulary (via Location)

Redirect Meaning
/home + Set-Cookie: sb-* password success — the session cookies ride on the 303 itself
/login?error=credentials password failure — one neutral error for wrong email, wrong password, or an exception (never says which)
/login?sent=1 link mode, live — always, whether the account exists, the send failed, or an exception threw (anti-enumeration; errors are server-logged only)
/login?error=email email failed format validation
/login?sent=demo / /login?error=demo keyless demo (link / password mode) — no emails, no credential checks
429 JSON shared rate-limit envelope

Notes for callers

  • Anti-enumeration is deliberate: on the live link path you cannot distinguish success from "no such account". Password success is detectable only as Location: /home.
  • Redirects use publicOrigin(): with PUBLIC_BASE_URL set, the Location host may differ from the host you called (keeps cookies off Netlify deploy-permalink hosts).
  • A redirect-following client re-requests with GET — disable redirects to inspect outcomes.
  • Config nuance: password mode needs both the secret key (isPlatformConfigured) and the publishable key (createRouteClient); secret-without-publishable behaves as demo at the client step.

POST /api/auth/logout

No body read. Always 303 → / — with the session-clearing Set-Cookie headers when a session existed. If Supabase signOut() errors (or throws), every sb-* cookie is force-expired on the redirect anyway (WSTG-SESS-06) — the browser session is always dropped, even when the server-side revocation failed. Fully idempotent; safe with stale or absent cookies. POST only (a bare GET link 405s). CSRF protection rests on SameSite cookie behavior.


GET /auth/confirm

The magic-link landing. Query params: token_hash (required), type (defaults email), next (post-login destination — sanitized to a relative in-app path; anything absolute/protocol-relative silently becomes /home, so there is no open redirect).

Redirect Meaning
${next} (default /home) + Set-Cookie: sb-* success — session minted
/login?error=expired missing token, verification failure, or any exception — every failure collapses to this one friendly reason
/login?sent=demo keyless demo (no sessions exist)

Notes for callers

  • The token is single-use and consumed by verification. A link-prefetching mail scanner that GETs the URL first burns it — never send a HEAD/GET "check" against a confirm URL you intend to use.
  • This is a GET with a state-changing side effect — treat as non-idempotent.
  • Session cookies arrive on the 303 response itself; capture Set-Cookie from the redirect, not from the followed page.

Source: app/api/auth/login/route.ts, app/api/auth/logout/route.ts, app/auth/confirm/route.ts, lib/auth.ts, lib/supabase/route-client.ts, proxy.ts