Decisions

ADR-141: Warp mobile-first auth UX — magic-link primary, threat model, session shape

[warp#1442](https://warp.gyrum.ai/items/1442) (EPIC: warp public identity — mobile-first home page + login + account) opens warp to phone access, which the laptop-only baseline never assumed. The current `/login`…

#141

ADR-141: Warp mobile-first auth UX — magic-link primary, threat model, session shape

Status: Proposed Date: 2026-05-06

Context

warp#1442 (EPIC: warp public identity — mobile-first home page + login + account) opens warp to phone access, which the laptop-only baseline never assumed. The current /login surface (web/src/routes/login/+page.svelte) renders three stacked paths in this order: bearer-token paste (primary), GitHub OAuth (probe-gated), magic-link (probe-gated). Token-paste is hostile on mobile — wrp_ followed by 32 hex chars in a type=password field is a 36-character round trip through the OS keyboard, paste-buffer, and a monospace input that scrolls horizontally on a 375px viewport. Until W2 ships, the mobile sign-in journey is "open laptop, get a token, paste it on phone".

Phone access also shifts the threat model. The laptop baseline assumed an attentively-watched device on a known network behind the operator's screen-lock, with cookies that survive across sessions because the browser's cookie store is not aggressively pruning them. Phones invert each of those: device-loss is more frequent (left in cabs, picked up by family), networks are untrusted (coffee-shop wifi, captive portals, opportunistic MITM), the keyboard is hostile to anything resembling a credential, and iOS Safari's Intelligent Tracking Prevention (ITP) caps first-party cookies at 7 days of inactivity even when Max-Age is longer. A session shape that survives the laptop baseline silently fails on iOS in week two.

Sister ADR W0.1 (warp#1443) covers the surface architecture — routing, layout, public-vs-authenticated split. This ADR (W0.2) covers the auth-method ordering, threat model, and session-cookie shape that W2.1 / W2.2 / W2.3 implement against. The two are deliberately separable: rearchitecting the public surface does not require changing the cookie shape, and tightening the cookie shape does not require touching the public surface.

The substrate this UX rests on already exists: api/internal/auth/magic_link.go (request + verify endpoints, per-email + per-IP rate limits, domain allowlist, 15-minute link TTL), api/internal/auth/github_oauth.go (start + callback, gyrum-labs org membership probe), and api/internal/auth/auth.go (the wrp_<32-hex> bearer token format pinned at the API). gyrum-go/pkg/auth documents an Argon2id + JWT + httpOnly cookie shape; warp does not adopt that library (warp's tokens are opaque + SHA-256 looked up, not JWTs), but its cookie attributes inform this ADR.

Decision

Warp's /login surface, starting in W2, presents auth methods in this order: magic-link primary (large CTA, mobile-keyboard friendly), GitHub OAuth secondary (probe-gated, one-tap for org members), token-paste tertiary (collapsed behind a "Developer sign-in" disclosure). Sessions are carried in a single cookie named warp_session with the attributes pinned below. Passkeys are deferred to W4.2 — the rationale is in Alternatives considered.

Auth-method ordering

Order Method Rendered as Why this rank
Primary Magic-link Email input + "Send magic link" button, full-width, top of card Lowest mobile friction: type one short string the OS already autocompletes (autocomplete="email"), tap a link in mail.app, land back in warp. No paste, no app-switch beyond the mail client. The substrate (magic_link.go) already enforces per-email + per-IP rate limits and a 15-minute TTL — the security posture is already there, only the UX rank changes.
Secondary GitHub OAuth Full-width "Sign in with GitHub" button, rendered only if /api/v1/auth/github/configured returns true One tap for gyrum-labs org members; OAuth round-trip works on iOS Safari because we use SameSite=Lax (Strict would kill it — see Cookie shape). Below magic-link because it requires (a) the deploy to have GitHub configured and (b) the user's GitHub account to be in the org — both can be false; magic-link works for everyone with an allowlisted email.
Tertiary Token-paste Hidden behind a <details> disclosure labelled "Developer sign-in (paste token)" Token-paste is the API's authoritative format and remains essential for CLI / scripting / power-user workflows, but it is the worst mobile UX and exposes an attractive credential to shoulder-surfers in public spaces. Hiding it behind a disclosure removes it from the default mobile journey without removing it from the surface. The disclosure itself is a ≥44pt tap target.

The probe-gated rendering pattern (one /configured endpoint per method, fail-closed to "hidden") stays as it is in the existing +page.svelte. What changes is the order in which the configured methods render, and the visual weight (the primary method gets gy-button--primary styling and full width; the tertiary is collapsed by default).

Session cookie shape

The session cookie is the same name and shape across magic-link verify and GitHub OAuth callback. W2.2 will widen its lifetime from the current 60-second one-shot handoff to a real session cookie; this ADR pins what that cookie looks like.

Attribute Value Why
Name warp_session Already in use by both magic-link and GitHub callback (callbackCookieName in github_oauth.go); keeps existing tests + e2e specs valid.
HttpOnly true (W2.2 onwards) The W0/W1 handoff cookie is HttpOnly=false because the SPA needs to lift the value into localStorage exactly once. W2.2 stops doing that lift and treats the cookie as the session itself; once the SPA no longer reads the value, HttpOnly=true removes the XSS-exfiltration vector. The 60-second handoff path is replaced, not co-existed-with.
SameSite Lax Strict kills the GitHub OAuth round-trip (the cookie is not sent on the cross-site github.com → /auth/github/callback redirect); None requires Secure and exposes the cookie to every cross-site context including image embeds. Lax is the only value that preserves the OAuth round-trip without broadening the surface.
Secure true Production deploys are HTTPS-only; dev (localhost:5173) gets the cookie either via the Secure exemption browsers grant to localhost or via a feature-flag in the backend that drops Secure when WARP_ENV=dev. Dev-only carve-out is named in the env, never inferred from the request.
Path / The session is whole-app; narrower paths (/api) break the SPA's /keys, /items, etc. browser-only routes.
Domain Set explicitly per env (warp.gyrum.ai, staging.warp.gyrum.ai, localhost) Default-omitted Domain ties the cookie to the exact host; an explicit value lets us share the session across api. and web. subdomains if W3 splits them. The value comes from a new WARP_COOKIE_DOMAIN env, defaulted to the request host, validated as a single hostname (no leading dot, no comma list).
Max-Age 30 days, with rotation on every authenticated request 30 days is the visible "stay signed in for a month" promise. Rotation on every request means the effective cookie lifetime is 30 days from last activity, not 30 days from sign-in — closer to how users mentally model "I was using it last week, why am I signed out". Rotation is a single-row UPDATE in the existing session table; cost is negligible. The alternative (rotate daily) was rejected because it produces visible signed-out-on-Monday gaps for users who only use warp during the work week — a 30-day inactivity-from-last-use window covers the same security posture without that gap.

W2.2 will land the rotation logic; this ADR pins the shape, not the implementation. The cookie is set by the API on every successful authenticated request via a single helper, so the rotation is centralised and impossible to forget.

iOS Safari ITP test plan

ITP caps non-interacted-with first-party cookies at 7 days of inactivity. "Interacted-with" means the user has typed into a form on the site or clicked through to the site from a different first-party tab in the last 30 days; if neither has happened, the 7-day cap fires regardless of Max-Age. The test plan W2 must execute before merging the rotation logic:

  1. Provision a fresh iOS Safari on a device that has not visited warp.gyrum.ai before (Settings → Safari → Clear History and Website Data, or a fresh device / simulator).
  2. Sign in via magic-link on the device. Assert the warp_session cookie is set with the attributes above.
  3. Close Safari. Do not open warp for 8 days (one day past the ITP threshold).
  4. Open Safari, navigate to warp.gyrum.ai, attempt an authenticated action (e.g. open /keys).
  5. Assert one of:
    • Pass A — graceful re-auth: the API returns 401, the SPA redirects to /login with a non-hostile message ("your session expired — sign in again to continue"). The user signs in via magic-link in <30 seconds.
    • Pass B — transparent extension: the cookie was rotated on the last visit before the 8-day gap, ITP did not fire because rotation counts as interaction, and the user lands on the destination page directly.
  6. Fail conditions: the user sees a generic browser-level "could not connect" page; the SPA shows an unhandled error toast; the user lands on a page that renders partially (auth-checked components missing data).

The test runs on real iOS Safari (not the iOS simulator's WebKit, which does not implement ITP). It is part of W2's exit criteria, not part of W0's exit criteria — this ADR pins the test, the implementation runs it.

Mobile keyboard considerations

Surface Decision
Magic-link email input type="email" (triggers the email-optimised keyboard with @ and .com keys), autocomplete="email" (tap one autocomplete chip), inputmode="email", autocapitalize="off", spellcheck="false".
Magic-link tap-through Email link uses a standard https://warp.gyrum.ai/auth/magic-link/verify?token=… URL — no custom URL scheme, no Universal Links registration. Mail.app opens it in Safari, which already has the warp_session cookie context if the user previously signed in on this device.
GitHub OAuth Same-tab navigation (window.location.assign), not pop-up. Pop-ups are blocked by default on iOS Safari and the OAuth round-trip needs the cookie jar — full-page works on every browser.
Token-paste When the disclosure is opened, the input gets autocomplete="current-password" (already set), inputmode="text", and a deliberately-sized monospace font that does not zoom on focus (font-size ≥ 16px per iOS Safari's auto-zoom-on-focus heuristic).

Touch target minimums

Every tappable element on /login is ≥44pt × 44pt (Apple HIG) and ≥48dp × 48dp (Android Material). The two minimums converge at ~44 CSS pixels at 1x, ~48 at the typical Android dp-to-px ratio; the implementation rule is "every button, link, disclosure trigger, and form input on /login has min-height: 48px and adequate padding". The existing gy-button design token already meets this; the disclosure trigger and any new icon-only buttons must explicitly hit the floor.

The card's overall layout uses max-width: 460px; margin: 4rem auto 1rem on desktop and padding: 1rem; margin: 1rem 0 on viewports ≤ 480px, so the buttons span the full available width on mobile and the tap targets are not cramped against the viewport edge.

Rate-limit + brute-force posture

Magic-link send is already rate-limited per-email (1 per minute, 6 per hour) and per-IP (12 per hour) in magic_link.go. This ADR ratifies those limits as the posture and adds:

  • Failed-verify backoff: the magic-link verify endpoint already invalidates a token after one use; a token that fails verification (wrong signature, expired, already-used) does not produce additional retry attempts — the user must request a fresh link, which itself rate-limits. No additional backoff needed.
  • Session rotation on suspicious activity: if the API observes a request from a different IP /24 than the one the session was minted on, it rotates the cookie value (issues a new session, invalidates the old) on the next request. This is not a hard sign-out — it is a defence in depth against session-token leak via, e.g., a logged URL on a shared proxy.
  • Token-paste rate limit: tertiary path, already gated by the wrp_<32-hex> format check + SHA-256 lookup in auth.go. Brute-forcing a 32-hex-char token is not within the threat model.

Consequences

What becomes easier:

  • W2.1 (/login redesign) has no design questions left — the order, the visual weights, the CTAs, and the disclosure shape are pinned. The implementation is a Svelte component reshuffle plus the <details> wrap.
  • W2.2 (session cookie hardening) has every cookie attribute pinned — the implementation is a single helper in auth.go plus a backfill of the existing setHandoffCookie call sites.
  • W2.3 (iOS Safari validation) has a runnable test plan — no further design needed before execution.
  • The threat model is named in writing, so future ADRs (W3 account UX, W4.2 passkeys) can extend it instead of re-deriving it.

What becomes harder:

  • We have signed up to maintain three auth paths. Each has its own rate-limit envelope, its own happy-path UI, and its own failure modes. The probe-gated rendering keeps unconfigured paths invisible, but the test surface is still 3× single-method.
  • Cookie rotation on every authenticated request is a single UPDATE per request, but it puts the session table on the hot path. If the table's footprint grows (current operator estimate: <10k active sessions fleet-wide), we may need an in-memory rotation cache; W2.2 will measure before deciding.
  • iOS Safari ITP is the operator's problem in perpetuity. WebKit ships ITP changes without notice; the W2.3 test must be re-run after every major iOS release that the operator's fleet uses, or we silently regress.
  • The 30-day-with-rotation lifetime is a longer credential window than the laptop baseline's de-facto behaviour. Device-loss scenarios are correspondingly more impactful — W3 must ship a "sign me out everywhere" surface (fleet-wide session invalidation), tracked separately.

Alternatives considered

  • Keep token-paste primary; add magic-link below it. Lost because it preserves the worst mobile UX as the default; everyone signing in on a phone has to scroll past a hostile credential field to find the friendly one. The cost of reordering is one component reshuffle; the cost of leaving it is every mobile user's first impression.
  • Magic-link primary, token-paste fully removed. Lost because token-paste is essential for CLI / scripting / agent workflows where there is no human to receive a magic-link email. Hiding it behind a disclosure (rather than removing it) preserves the workflow without harming the default mobile journey.
  • Session cookie SameSite=Strict. Lost because it kills the GitHub OAuth round-trip — the cross-site redirect from github.com back to /auth/github/callback would not carry the cookie, and the callback could not associate the OAuth state with the session it was meant to issue. Lax is the published-correct value for first-party-with-OAuth surfaces.
  • Session cookie Max-Age=24h, no rotation. Lost because it produces a visible signed-out gap every day for users who use warp intermittently — the very behaviour we are trying to avoid by going mobile-first. The rotation-on-activity model gives the same effective security (idle sessions die in 30 days; active sessions never die for active users) without the UX cost.
  • Passkeys (WebAuthn) as primary. Deferred to W4.2. Passkeys are the strictly-better long-term answer — phishing-resistant, no shared secret, OS-level UX. They are not the W2 answer because (a) the substrate (pkg/auth doesn't yet expose a WebAuthn ceremony, and the magic-link / OAuth substrate already does), (b) cross-device passkey portability via iCloud Keychain / Google Password Manager is still rolling out unevenly across our user base, and (c) shipping passkeys correctly is a multi-week engagement (registration ceremony, attestation handling, fallback flows) that would block W2's mobile launch by a quarter. W4.2 picks them up once the W2 mobile baseline is in operation and we have measurement on which auth methods actually get used.
  • Native mobile app (iOS / Android). Out of scope. EPIC warp#1442 is an explicitly mobile-web effort; native is a separate quarter-scale decision and would supersede this ADR if taken, not extend it.

Supersedes: none Superseded by: (blank)

Sister ADRs: W0.1 architecture ADR (warp#1443) — surface architecture and public-vs-authenticated split, shipping in parallel.

Refs (cross-repo file paths below resolve in gyrum-labs/warp, not in this repo):

  • warp#1442 — parent EPIC
  • warp#1353 — sister: warp has no admin scope reachable from browser (affects token-paste tertiary path framing)
  • api/internal/auth/magic_link.go — magic-link substrate this ADR ratifies
  • api/internal/auth/github_oauth.go — OAuth substrate; pinned callbackCookieName = "warp_session" is reused
  • api/internal/auth/auth.gowrp_<32-hex> token format
  • gyrum-go/pkg/auth — Argon2id + JWT + httpOnly reference shape (informs but is not adopted)