Skip to content

Decision: Magic Link Tokens Shrink From 64 to 8 Characters

Magic link tokens were originally generated with Str::random(64), which produces authentication URLs unwieldy to deliver over SMS and WhatsApp [@design-doc]. The change that shrank them to 8 characters is a small diff — one call in MagicLink::generateToken() and one validation rule — but it rests on a specific argument about what magic links actually are in this system: short-lived, single-use-by-default (later flipped to reusable-by-default, see Magic Link Single-Use Default Incident), clicked-not-typed credentials, where extreme entropy buys little.

Context

Every extra character in a magic-link token is a character an SMS gateway has to carry and a character that makes the URL look less trustworthy to a user deciding whether to tap it. The 64-character token was sized for a security model — long-lived, retyped, brute-force-resistant secrets — that doesn't match how these links are actually used: they arrive with a default 72-hour expiry, and the user's only interaction is a single tap, never manual entry [@design-doc]. The design considered two alternatives and rejected both: a human-friendly restricted alphabet (stripping ambiguous characters like 0/O/l/1/I) was rejected because links are never typed, so readability doesn't matter, and per-channel token length variance was rejected because one consistent contract is easier to reason about and test than several [@design-doc].

Decision

MagicLink::generateToken() — the model, not MagicLinkService — is the single source of truth for the token contract [@design-doc]. It now generates Str::random(8) and is collision-aware: it queries for an existing token with the same value and retries, up to five attempts, throwing a RuntimeException if every attempt collides [@magic-link-model]. Putting the retry loop in the model rather than the service was a deliberate choice so that any future caller generating a token goes through the same collision-checked path rather than each service re-implementing its own retry logic [@design-doc]. VerifyMagicLinkRequest's validation rule tightened from size:64 to size:8 to match [@design-doc].

The magic_links.token database column deliberately stayed at varchar(64). There was historical production data at that width, and shrinking the column would mean deleting or truncating existing rows for no functional benefit — the 8-character contract is enforced entirely at generation (the model) and at the API boundary (the form request), which is sufficient without touching the schema [@design-doc].

Status

Approved and implemented. MagicLink::generateToken() in the current codebase matches the design exactly: 8-character Str::random(), a where('token', $token)->exists() collision check, a five-attempt cap, and a RuntimeException on exhaustion [@magic-link-model].

Consequences

Magic links are now short enough to sit comfortably inside an SMS or WhatsApp template body without visually dominating the message. The collision-retry contract living in the model means any new call site that needs a token gets the same safety guarantee automatically, rather than needing to remember to add its own retry loop.

The tradeoff is entropy: 8 alphanumeric characters is roughly 47 bits, a meaningful reduction from 64 characters' worth of entropy. The design's own risk section is explicit that this is acceptable only if the verify endpoint is rate-limited, and just as explicit that confirming or adding that rate limit was out of scope for this change — a separate concern, not something this refactor was responsible for verifying [@design-doc]. That gap surfaced concretely in the incident recorded in Magic Link Single-Use Default Incident, whose follow-up list independently flags the same missing rate limit on POST /api/auth/magic-link/verify. Anyone reasoning about magic-link security in this codebase should treat token length and verify-endpoint throttling as one combined question, not two independent ones. See Auth Architecture for where magic links sit relative to Sanctum, Passport, and WorkOS.