ADR-0002: Email sign-in uses a 6-digit code, not a link
- Status: Accepted
- Date: 2026-07-20
- Deciders: Toby
Context
No user had ever completed an email sign-in on the Hetzner deployment. The GoTrue audit log showed seven user_confirmation_requested events for a single reviewer across three weeks and zero `GET /auth/v1/verify` hits in the deployment's entire history — every successful login had gone through /token, i.e. a password.
Two independent defects were found, either of which alone is fatal.
1. The emitted links do not resolve. GoTrue builds mailer links from API_EXTERNAL_URL but drops the base path — a known limitation when it runs behind a path-prefix reverse proxy. API_EXTERNAL_URL is https://ventures.wait-what.co/stratify-supabase, yet the emitted link is:
https://ventures.wait-what.co/auth/v1/verify?token=… -> 503
https://ventures.wait-what.co/stratify-supabase/auth/v1/verify?token=… -> 303infra/hetzner/traefik-stratify.yaml routes only /stratify-supabase and /stratify on that host, so nothing serves /auth/v1. Traefik answers 503 _before_ the request reaches GoTrue, which is why the auth log records nothing at all — the absence of /verify hits was misread as "nobody clicks" when it was "every click dies at the proxy".
2. The mobile redirect target could not have worked either. The app passed emailRedirectTo: 'stratify://feed', a custom URL scheme. Mail clients open links in an embedded WebView that will not hand off to a custom scheme, so even a correctly-routed link dead-ends on iOS Gmail.
Deliverability was investigated first and found healthy: SES has production access, reports 0 bounces / 0 complaints / 0 rejects, and the recipients are not suppressed. Mail was arriving. The links were dead.
Decision
Both clients sign in with a 6-digit email OTP, verified via supabase.auth.verifyOtp({ email, token, type: 'email' }). The confirmation and magic-link templates render {{ .Token }} and carry no link at all.
GOTRUE_MAILER_OTP_EXP is pinned to 600s. GoTrue's default is 24h, which is far too wide for a 10⁶ keyspace against a per-IP-only verify limiter.
A password path remains, given equal visual weight in both sign-in forms. It is the fallback whenever email delivery is the thing failing, so it must be findable.
Consequences
- Sign-in no longer depends on URL routing, deep-link registration, or how a mail
client handles redirects. The whole class of failure is gone.
- A code is client-agnostic and works when the app and the mail are on different
devices — which the stratify:// deep link never did.
- Codes must be typed. Mitigated with
autoComplete="one-time-code", paste
normalisation, and auto-submit on the sixth digit.
- `recovery` and `invite` now use codes too (added after the initial decision).
apps/web/app/reset-password verifies the code (type: 'recovery' or 'invite', taken from a ?type= param) to obtain a session, then calls updateUser to set the password. Both templates carry the code plus a link to the web app — an ordinary page URL, not a GoTrue /auth/v1 link, so it resolves.
- `email-change` is the one flow still on a link, and it is still broken. Nothing in
either client calls updateUser({ email }), so it is unreachable; giving it a code would mean building an entry point for a flow no one can trigger. Revisit if email change is ever offered.
- Password signup now uses the same code, via `type: 'signup'`. Both apps gained
a create-account mode (auth.signUp({ email, password })) alongside the existing password sign-in / OTP sign-in modes, sharing this ADR's code-entry UI and confirming with verifyOtp({ type: 'signup' }). This is what finally exercises the confirmation template — previously dead, since nothing called signUp or inviteUserByEmail anywhere in the app.
OTP_LENGTHinpackages/shared/src/otp.tsis now coupled to
GOTRUE_MAILER_OTP_LENGTH. If they drift, the client truncates and every verify fails with no useful diagnostic. Both are pinned, in code and in the compose override, with comments pointing at each other.
Alternatives considered
Add a Traefik route for `/auth/v1`. Would unbreak links, password reset and invites in one change. Rejected for now: ventures.wait-what.co is shared with ecosystem-studio, and claiming a root-level /auth/v1 on a shared host to work around a link format we no longer need is the wrong trade. Still the likely fix for recovery/invite.
Universal Links (apple-app-site-association). Fixes the mobile scheme problem but not the 503, needs an association file plus app entitlements, and leaves web depending on the same broken URL.
Move GoTrue to its own subdomain. Cleanest — no base path, so nothing to strip. Rejected as too large for the immediate fix; worth revisiting if link-based flows are needed again.
Ship both a code and a link in one email. Rejected: the link 503s, so it would be a prominent button that fails. Both also share one underlying token, so using either consumes the other.