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(): withPUBLIC_BASE_URLset, theLocationhost 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-Cookiefrom 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