Skip to content
This repository was archived by the owner on Oct 5, 2026. It is now read-only.

Magic links: single-use, prefetch-proof, and the thing that verifies an address - #43

Merged
ParallelEntrepreneur merged 1 commit into
mainfrom
feat/magic-links
Sep 7, 2026
Merged

ParallelEntrepreneur merged 1 commit into
mainfrom
feat/magic-links

Conversation

@ParallelEntrepreneur

Copy link
Copy Markdown
Contributor

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 /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 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_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 makes it fail, which is the only reason to believe it.

What a caller can never learn

  • A known and an unknown address get the same 202 and the same body. 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, say — is refused here rather than turned into a session.
  • The address never reaches a log. A log line naming who asked to sign in is exactly what this endpoint refuses to say out loud.

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_to rather than the client's own state.

🤖 Generated with Claude Code

https://claude.ai/code/session_01V8K9TBtmnf29dV6uEP5oM3

…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
@ParallelEntrepreneur
ParallelEntrepreneur merged commit 4f19e79 into main Sep 7, 2026
7 checks passed
@ParallelEntrepreneur
ParallelEntrepreneur deleted the feat/magic-links branch September 7, 2026 18:11
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant