Skip to content

ADR-023: Authentication Strategy — Passwordless Magic Link with JWT

  • Status: Accepted - partially superseded by ADR-079 (agent clients are authorized through OAuth 2.1, not the magic link)
  • Date: 2026-03-25
  • Depends on: ADR-022 (Persistent Storage Strategy)
  • Enables: #76 (Auth implementation), #80 (Shared read-only trips)

Context and Problem Statement

Bike Trip Planner needs an authentication layer to associate trips with users, control access, and enable features like trip listing, duplication, and sharing. The application follows a decoupled architecture (Symfony API backend + Next.js frontend) and must support both browser and future native mobile clients.

The authentication mechanism must be:

  • Stateless — consistent with the existing API Platform architecture (no server-side sessions)
  • Self-hostable at zero cost — no dependency on paid identity providers
  • Mobile-compatible — functional in a native mobile client where cookie behavior is unreliable
  • Low friction — minimize user effort for an invite-only audience of bikepacking enthusiasts

Invite-Only Model

The application does not offer self-registration. Users are created exclusively via a CLI command by the administrator. This eliminates the need for a registration flow, email verification during sign-up, and password reset mechanisms — dramatically simplifying the authentication surface.


Decision Drivers

  • Security — minimize attack surface (no password database to breach, no credentials to phish)
  • Simplicity — reduce implementation and maintenance burden for a single-developer project
  • Stateless architecture — preserve the existing API Platform State Provider/Processor pattern
  • Multi-platform — must work in browser and a native mobile client without behavioral differences
  • Cost — zero infrastructure cost; SMTP via Resend free tier (100 emails/day)

Considered Options

Approach Stateless? Mobile? Self-hosted $0? Complexity
JWT + magic link Yes Yes Yes Medium
JWT + login/password Yes Yes Yes Medium
Session cookies No Fragile Yes Low
API token (DB lookup) No Yes Yes Medium
OAuth2 server Yes Yes Yes High

The user enters their email address. If the email corresponds to an existing user, a time-limited magic link is sent. Clicking the link authenticates the user and issues a JWT access token + refresh token pair. No password is ever stored, transmitted, or remembered.

Pros:

  • Zero password storage eliminates an entire class of vulnerabilities (credential stuffing, brute-force, password reuse)
  • Minimal UI surface: one input field, one button
  • Natural fit for invite-only: the admin creates users with just an email
  • Stateless JWT integrates cleanly with API Platform

Cons:

  • Depends on email delivery reliability
  • Slight latency (user must switch to email client)

Option B: JWT + Login/Password

Traditional email + password authentication with JWT tokens.

Pros:

  • Well-understood pattern with extensive library support
  • No dependency on email delivery for every login

Cons:

  • Requires password hashing, storage, and reset flow
  • Increases attack surface (credential stuffing, brute-force, password reuse attacks)
  • More UI to build and maintain (registration, login, forgot password, reset password)
  • Overkill for an invite-only audience

Option C: Session Cookies

Server-side sessions stored in Redis or database.

Pros:

  • Simplest implementation, native Symfony support
  • Easy revocation (delete session)

Cons:

  • Breaks stateless architecture; requires sticky sessions or shared session store
  • Fragile in a native mobile client (cookie partitioning, ITP restrictions)
  • Does not scale to multiple API instances without shared state

Option D: API Token (Database Lookup)

Long-lived opaque tokens stored in the database, sent as Bearer header.

Pros:

  • Simple to implement
  • Easy revocation (delete token row)

Cons:

  • Database lookup on every request — not stateless
  • Long-lived tokens increase window of compromise
  • No standard refresh mechanism

Option E: OAuth2 Server

Full OAuth2 authorization server (e.g., league/oauth2-server-bundle).

Pros:

  • Industry standard, supports multiple grant types
  • Future-proof for third-party integrations

Cons:

  • Significant implementation complexity for a single-developer project
  • Requires managing clients, scopes, consent screens
  • Massive overkill for an invite-only application with no third-party consumers

Decision

JWT + passwordless magic link using LexikJWTAuthenticationBundle.

Authentication Flow

┌─────────┐         ┌──────────┐         ┌─────────┐         ┌──────────┐
│ Browser  │         │ Next.js  │         │ Symfony  │         │  Resend  │
│          │         │ Frontend │         │ Backend  │         │  (SMTP)  │
└────┬─────┘         └────┬─────┘         └────┬─────┘         └────┬─────┘
     │  Enter email       │                    │                     │
     │───────────────────►│  POST /auth/request-link  │                     │
     │                    │───────────────────►│                     │
     │                    │                    │  Generate token      │
     │                    │                    │  (256-bit entropy)   │
     │                    │                    │                     │
     │                    │                    │  Send magic link     │
     │                    │  202 Accepted      │────────────────────►│
     │                    │◄───────────────────│                     │
     │  "Check your email"│                    │                     │
     │◄───────────────────│                    │                     │
     │                    │                    │                     │
     │  Click magic link  │                    │                     │
     │───────────────────────────────────────►│                     │
     │                    │                    │  Validate token      │
     │                    │                    │  (TTL + single-use)  │
     │                    │                    │                     │
     │                    │  JWT access token   │                     │
     │                    │  + refresh cookie   │                     │
     │◄───────────────────────────────────────│                     │
     │                    │                    │                     │

Token Strategy

Token Storage Lifetime Purpose
Magic link token PostgreSQL (magic_link table), SHA-256 hashed at rest 30 min, single use One-time authentication
Access token (JWT) In-memory (JavaScript variable) 15 min API request authorization
Refresh token Returned in the API response body (the API is client-agnostic, sets no cookie); the web BFF keeps it in an HttpOnly SameSite=Lax cookie it owns, the native client in the platform's secure storage. Backing row in refresh_token, encrypted at rest (see note) 30 days Silent access token renewal

Token-at-rest hardening (2026-07 security audit — token-at-rest finding; distinct from the audit-report.md SEC-003 clickjacking item already fixed in #630). The magic-link and email-change tokens are high-entropy random values that ARE the credential, so only their SHA-256 hash is persisted (the plaintext travels in the link and is hashed on verify). This supersedes an earlier draft of this ADR that described the column as token (hashed) without the code implementing it.

The refresh token is encrypted at rest rather than hashed: the grace-window rotation (recette #649) must re-serve the successor token value on a reload race, which a one-way hash makes impossible. The token column holds a reversible libsodium ciphertext (RefreshTokenEncryptor, keyed by the app's REFRESH_TOKEN_ENC_KEY) and rows are looked up by a deterministic token_digest (SHA-256) — so a database read yields neither a usable token nor its plaintext. Rotating the encryption key invalidates stored refresh tokens (users transparently re-login).

The link carries the token in its fragment (2026-09 log audit). The emailed links are {FRONTEND_URL}/auth/verify#<token> and {FRONTEND_URL}/account/email-change/verify#<token>, not /verify/<token>. A path segment reaches the Caddy and Traefik access logs on the way in and rides as Referer on every request the landing page makes; a browser never sends the fragment. The page (web) or the deep-link screen (mobile) reads it, erases it from the address bar, and POSTs it exactly as before, so the verify endpoints did not change. Those pages also send Referrer-Policy: no-referrer.

Uniform Response Policy

All requests to the magic link generation endpoint return the same 202 Accepted response with a neutral message ("If this email is registered, a login link has been sent"), regardless of:

  • Whether the email exists in the database
  • Whether a valid magic link already exists for this email
  • Whether the throttling limit has been reached

This prevents user enumeration and leaks no information about the internal state of the system.

If a valid (non-expired, non-consumed) magic link already exists for a given email, no new link is generated or sent. This prevents the multiplication of valid tokens and reduces SMTP usage.


Security Measures

Token Security

  • Magic link entropy: 256 bits (cryptographically secure random bytes), making brute-force infeasible
  • Magic link TTL: 30 minutes, single use — consumed immediately upon verification
  • Access token: stored in JavaScript memory only (never localStorage or sessionStorage) — immune to XSS exfiltration via storage APIs
  • Refresh token: the API returns it in the response body and sets no cookie of its own. On the web, a Next.js BFF (src/app/api/auth/*) exchanges the body token for an HttpOnly SameSite=Lax cookie it owns — inaccessible to browser JavaScript. Lax (not Strict) is deliberate: the magic-link landing is a top-level cross-site navigation and a Strict cookie would not ride it (#649). CSRF on the cookie-bearing /api/auth/refresh|logout routes is blocked by a same-origin guard (Sec-Fetch-Site, with an Origin fallback), not by SameSite=Strict.

Throttling

Rate limiting on the magic link generation endpoint to prevent mailbox spam and SMTP quota exhaustion:

  • Per email: max 3 requests per 15-minute window
  • Per IP: max 3 requests per 15-minute window
  • Implemented via Symfony RateLimiter component with Redis backend

Client-Agnostic API + Web BFF

The API is client-agnostic: /auth/verify and /auth/refresh always return { token, refresh_token } in the response body and set no cookie. There is no client detection — the backend does not care who calls it.

  • The web never sees the refresh token in JavaScript. A Next.js BFF (src/app/api/auth/*) sits in front of the API: it forwards the browser's request, takes the refresh_token from the API body, stores it in an HttpOnly SameSite=Lax cookie it owns, and returns only the short-lived JWT to the browser. On refresh, the BFF reads its cookie, calls the API, re-pins the rotated token, and again hands back only the JWT. Cross-site abuse of the cookie-bearing routes is blocked by a same-origin guard (Sec-Fetch-Site, Origin fallback → 403).
  • A native mobile client (see ADR-053) does not carry a browser cookie jar, so it talks to the API directly and keeps the body refresh_token in the platform's secure storage.

An earlier Capacitor-specific mechanism (client detection keyed to the WebView's capacitor:// Origin, a capacitor:// entry in the CORS_ALLOW_ORIGIN allow-list, and an X-Mercure-Token response header so the WebView could read the subscriber JWT) has been removed as dead code along with the Capacitor client. It is superseded by the client-agnostic body-token + web-BFF design above (#1010) — no client detection is reintroduced.

The native client is no longer hypothetical: the monorepo is materialized as npm workspaces (core / pwa / mobile) and the Expo / React Native app talks to this client-agnostic API directly, keeping the body refresh_token in expo-secure-store (see ADR-053). The Mercure subscriber JWT — which the browser reads from an HttpOnly cookie the native client cannot — is delivered to the mobile client through a readable-body endpoint on the same no-client-detection principle (ADR-056).


Technical Implementation

Backend (Symfony / API Platform)

  • LexikJWTAuthenticationBundle for JWT generation and validation
  • Custom authenticator for magic link token verification
  • Doctrine entity MagicLink (table magic_link) with columns: token (SHA-256 hash of the emitted value), user_id (FK), expires_at, consumed_at
  • Symfony Mailer with Resend SMTP transport for magic link delivery
  • Symfony RateLimiter for throttling (Redis-backed sliding window)
  • Custom API Platform State Processor for the /auth/request-link endpoint

Frontend (Next.js)

  • Access token held in a Zustand store (in-memory, not persisted)
  • fetch wrapper automatically attaches Authorization: Bearer header
  • Silent refresh via credentials: 'include' on the refresh endpoint
  • Redirect to login page when both tokens are expired

Prerequisites

  • PostgreSQL + Doctrine (ADR-022, #56) — required for MagicLinkToken entity and User entity persistence
  • SMTP provider configured (Resend free tier)

Consequences

Positive

  • Zero password surface — no password hashing, no reset flow, no credential database to breach
  • Minimal UI — single email input for authentication; no registration form needed
  • Stateless API — JWT validation requires no database lookup on regular requests
  • Invite-only simplicity — user creation via CLI eliminates self-registration complexity
  • Email as identity — the magic link implicitly proves email ownership

Negative

  • Email dependency — authentication is blocked if email delivery fails or is delayed
  • Context switching — users must leave the app to check their email
  • SMTP cost at scale — Resend free tier limits to 100 emails/day (sufficient for invite-only, but would need upgrade for public access)

Neutral

  • Refresh token rotation — a future enhancement could invalidate the previous refresh token upon each renewal (detect token theft)
  • Device management — users cannot currently see or revoke active sessions across devices

References