Decision: Magic Link single_use Defaults to False, Not True¶
Users authenticating over SMS or WhatsApp received a magic link that worked once and then, on any second tap within its still-valid 72-hour window, returned HTTP 401 auth.magic_link.invalid_or_expired [@incident-doc]. The link had not expired by the clock; it had been silently marked used the first time it was verified. The root cause and the fix are both small, but the incident is worth recording because it names a general rule for when a link-based credential should be single-use versus reusable — a rule that generalizes past magic links to any future auth primitive built the same way.
Context¶
MagicLinkService::generate() accepted bool $singleUse = true as its default, and that default reached every call site that didn't explicitly override it — including the bare generate($user) call inside MagicLinkController that handles user-initiated "send me a link" requests over SMS and WhatsApp [@incident-doc]. MagicLink::isValid() treats single_use && isUsed() as invalid regardless of expires_at, and exchangeForToken() stamps used_at on the first successful verify whenever single_use is true — so the first tap of a link succeeded and consumed it, and every subsequent tap within the 72-hour window failed the used-check even though the expiry check would have passed [@incident-doc].
The default was wrong because it modeled magic links on password-reset links, where one-shot consumption is the norm because the legitimate flow only ever clicks once. Messaging-channel auth is a different flow: the link sits in a thread the user can reopen, and re-tapping the same message — to switch devices, recover from a closed tab, or just because a first tap raced a network blip — is routine, expected behavior, not an attack [@incident-doc]. Four call sites in the codebase already understood this and passed singleUse: false explicitly: AlertDispatchService, PostAlertRetargetingService, SmsTemplateVariableBuilder, and ProcessMarketingFlows [@incident-doc]. The wrong default persisted specifically because the code that knew the right answer had to keep restating it at every call site, and the one call site that didn't restate it — the user-initiated PWA send path — inherited the bug.
Decision¶
Flip the default: public function generate(User $user, ?int $expiryHours = null, bool $singleUse = false, ?array $context = null): MagicLink [@incident-doc]. The current code confirms this is the shipped state [@magic-link-service]. Callers that need one-shot semantics now opt in explicitly by passing singleUse: true; every other mechanism — the single_use column, MagicLink::markAsUsed(), the isValid() guard — was left untouched, because the capability itself was never the bug, only which value reached the entry point by default [@incident-doc] [@magic-link-model]. The factory default (database/factories/MagicLinkFactory.php, 'single_use' => true with a reusable() state) and the migration's column default (->default(true)) were both deliberately left as-is: the factory default only affects test data unless a test opts into reusable(), and the column default is a safety net for any insert path that doesn't specify the flag, neither of which needed to change for this fix to be correct [@incident-doc].
Status¶
Shipped to production (commit 105ce5d, PR #221 merging PR #220) and verified with production smoke tests on 2026-05-04, not just reviewed [@incident-doc]. A reflection check against the running container confirmed the third parameter's default value reported false [@incident-doc]. Five sequential verify calls against one reusable link all returned 200 with distinct Sanctum tokens and used_at staying NULL; a parallel single-use link still correctly rejected its second and third verify attempts with 401; and a manually expired reusable link still correctly rejected on the expiry branch — proving the fix didn't erase either the reusable path or the still-needed single-use and expiry paths [@incident-doc].
Consequences¶
The generalizable rule the incident write-up settled on: single_use should default to false whenever a link travels over a channel with history the user can scroll back to (SMS, WhatsApp, Telegram, any messaging thread) or where prefetching can consume a click before the user sees it (some in-app browsers). It is fine to default true only when the container disappears after one interaction — a push-notification deep link, an email opened once in a single tab. The expires_at window, not single_use, is treated as the real security boundary; single_use is a stricter rule layered on top that needs its own justification, not a reflexive default [@incident-doc].
Production verification also surfaced three follow-ups that were deliberately not fixed in the same change, because none of them block the fix itself. First, exchangeForToken() calls $user->createToken($deviceName) unconditionally on every verify, so a reusable link now mints one Sanctum personal access token per tap instead of the single token a single-use link implicitly capped it at — observed directly in the smoke test as five distinct token IDs from one link [@incident-doc]. Second, the Mixpanel post_alert_app_session_started event fires once per verify rather than once per link, inflating funnel and reach metrics for FSPs unless decoupled from "token issued" [@incident-doc]. Third, POST /api/auth/magic-link/verify has no rate limiting at all — tolerable when tokens were 256 bits of entropy from Str::random(64), but a real gap paired with the token-length reduction in Magic Link 8-Char Tokens, whose own risk section names this same missing rate limit as the mitigation its reduced entropy depends on [@incident-doc]. See Auth Architecture for where this sits in the broader magic-link and Sanctum flow.