This repository was archived by the owner on Oct 5, 2026. It is now read-only.
Repository navigation
Magic links: single-use, prefetch-proof, and the thing that verifies an address - #43
Merged
Merged
Conversation
…an address Implements #21, and with it closes the half of #12 the password work left. A magic link is a bearer credential sent over email, a channel this service does not control and cannot audit. Everything here follows from that: 32 random bytes, stored only as a SHA-256, fifteen minutes, and single-use under concurrency rather than by convention. It is also the answer to three things other modules deliberately left undone. Password registration creates an account with `primary_email_verified = 0`, because registering must not be a way to claim somebody else's address — consuming a link sent to that address is the proof, and the linking rules only ever auto-link a verified one. A password lockout is not a dead end. And a passwordless account has no other way in at all. **Mail clients prefetch links.** Outlook, corporate scanners and several mobile clients fetch every URL in a message to check it. A prefetch that consumed a single-use token would sign nobody in and leave the person holding a link that has already been used, which is the failure that makes people give up on magic links entirely. So `GET /consume` only signs somebody in when the request looks like a person clicking: `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: document`, which every current browser sends on a top-level navigation and an out-of-band fetch does not. Anything else gets a confirm button. The limits are written down rather than implied. It does not catch a scanner that copies a browser's headers or renders in a real engine. What it must never do is refuse a real person, so a request with no fetch metadata at all — an old browser, a stripped proxy — gets the button rather than a refusal: one click, works everywhere. The prefetch page is also not an oracle, because a real token and an invented one produce the same page and nothing is read or spent to produce it. **Single-use is the store's guarantee, not the handler's.** `consume_single_use_token` updates `where consumed_at is null and expires_at > now` and checks the affected row count, so two simultaneous consumes give one session and one refusal. The test drives both futures together, and replacing that statement with a read-then-write fails it — which is the only reason to believe the test. What a caller can never learn: a known and an unknown address get the same 202 and the same body, and so do a disabled account and a string that is not an address. Expired, already-used and never-issued tokens fail identically. A token of another kind — an authorization code — is refused rather than turned into a session. And the address never reaches a log, because a log line naming who asked to sign in is exactly what this endpoint refuses to say out loud. Registration by link is **off by default**: with it on, anyone can create an account for any address they can type. A venture that wants passwordless sign-up turns it on knowing that. The mail renders through the harness template registry, so a venture can override the wording, with the compiled default as the fallback. The text part carries the raw link and the HTML part carries it twice, once as a button and once as copyable text, for clients that strip anchors. auth-core gains `set_primary_email_verified`, which is only ever called after this proof. 396 workspace tests (was 368), clippy clean, rustfmt clean, wasm32 clean with no openssl/reqwest/mio, cargo deny passes. Not done, and in the README rather than implied: no resend throttle beyond the rate limiter, and the link carries a `return_to` rather than the client's own `state`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8K9TBtmnf29dV6uEP5oM3
This was referenced Sep 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements #21, and with it closes the half of #12 the password work left.
A magic link is a bearer credential sent over email — a channel this service does not control and cannot audit. Everything follows from that: 32 random bytes, stored only as a SHA-256, fifteen minutes, single-use under concurrency rather than by convention.
It is also the answer to three things other modules deliberately left undone. Password registration creates an account with
primary_email_verified = 0, because registering must not be a way to claim somebody else's address — consuming a link sent to that address is the proof, and the linking rules only ever auto-link a verified one. A password lockout is not a dead end. And a passwordless account has no other way in at all.Mail clients prefetch links
Outlook, corporate scanners and several mobile clients fetch every URL in a message to check it. A prefetch that consumed a single-use token would sign nobody in and leave the person holding a link that has already been used — the failure that makes people give up on magic links entirely.
So
GET /consumeonly signs somebody in when the request looks like a person clicking:Sec-Fetch-Mode: navigateandSec-Fetch-Dest: document, which every current browser sends on a top-level navigation and an out-of-band fetch does not. Anything else gets a confirm button.The limits are written down rather than implied. It does not catch a scanner that copies a browser's headers, or one that renders the message in a real browser engine. What it must never do is refuse a real person, so a request with no fetch metadata — an old browser, a stripped proxy — gets the button rather than a refusal: one click, works everywhere.
The prefetch page is also not an oracle: a real token and an invented one produce the same page, and nothing is read or spent to produce it.
Single-use is the store's guarantee, not the handler's
consume_single_use_tokenupdateswhere consumed_at is null and expires_at > nowand checks the affected row count, so two simultaneous consumes give one session and one refusal.The test drives both futures together — and replacing that statement with a read-then-write makes it fail, which is the only reason to believe it.
What a caller can never learn
202and the same body. So do a disabled account and a string that is not an address.Two deliberate defaults
Registration by link is off. With it on, anyone can create an account for any address they can type. A venture that wants passwordless sign-up turns it on knowing that.
The TTL is bounded at 60–86400 seconds. A link that lasts a day is a password with a long tail; one that lasts a minute does not survive a slow mail queue.
The mail
Rendered through the harness template registry under
auth-magic-link.sign-in, so a venture can override the wording, with the compiled default as the fallback (which is what keeps the conformance kit working). The text part carries the raw link, because a client showing only text must still be usable, and the HTML carries it twice — button and copyable text — for clients that strip anchors.396 workspace tests (was 368), clippy clean, rustfmt clean, wasm32 clean with no openssl/reqwest/mio, cargo deny passes.
Not done
In the README rather than implied: no resend throttle beyond the rate limiter, and the link carries a
return_torather than the client's ownstate.🤖 Generated with Claude Code
https://claude.ai/code/session_01V8K9TBtmnf29dV6uEP5oM3