An expired or already-used magic link can mean different things: the token aged out, another request consumed it, or a scanner visited it before the person did. Trace token creation and consumption separately. A GET that only displays a confirmation page avoids one common premature-consumption path.
Before you start #
- For
- Developers building and operating application email.
- Bring
- Node.js 22+ and npm. No account system, mailbox, or database is required.
- Scope
- The download runs locally with synthetic data. All email delivery is simulated; no provider credentials or live sends are needed.
Separate expiry from consumption #
Begin with the token’s issue time, expiry time, and the first state transition recorded by your authentication service. Keep only a safe correlation ID in diagnostics. Do not paste the token or full magic-link URL into a support ticket, analytics event, or access log.
A delayed email can carry a token that has expired naturally. An already-used response suggests a different path: a previous browser tab, another device, a retry, or an automated visitor may have consumed it. Record what the backend knows instead of assuming every early visit came from a scanner.
Inspect whether your email provider rewrites links for click tracking. A secret-bearing authentication link should not acquire extra tracking intermediaries. Also check that the message’s stated lifetime matches the account service’s actual expiry.
Reproduce the scanner sequence #
Unzip the lab, run npm ci, npm test, and npm run demo. The experiment issues three synthetic tokens under a controlled clock. It compares a GET that consumes a token with a GET that only returns “confirmation required,” then advances time to the exact expiry boundary.
The fixture stores token hashes in memory and never connects to an account, browser session, or provider. Restarting it clears its state. The experiment demonstrates lifecycle decisions; it is not a reusable authentication backend.
npm ci
npm test
npm run demoRead the key part of the example #
This excerpt comes from lab.mjs, lines 20–32, in the download. It shows the central decision; run the complete package with the commands above.
visit(token, method, flow) {
if (typeof token !== "string" || !/^[a-zA-Z0-9_-]{32}$/.test(token))
return "invalid";
const row = records.get(hash(token));
if (!row) return "invalid";
if (row.used) return "used";
if (now() >= row.expires) return "expired";
if (!["GET", "POST"].includes(method)) return "method rejected";
if (flow === "confirm" && method === "GET")
return "confirmation required";
if (!["confirm", "consume-on-get"].includes(flow)) return "invalid flow";
row.used = true;
return "consumed";Find the files you’ll change #
Pinned dependencies: Node.js built-ins only. Install with npm ci so the lockfile controls the resolved versions.
| File | Purpose |
|---|---|
| lab.mjs | The example behavior shown in this article. |
| test.mjs | Acceptance checks and synthetic failure cases. |
| demo.mjs / expected-output.json | A repeatable local experiment and its recorded result. |
| README.md | Setup commands, expected behavior and production boundaries. |
| BUILD-BRIEF.md | The coding-agent brief below. |
| package.json / package-lock.json | Pinned dependencies and runnable commands. |
| LICENSE | MIT license for adapting this example. |
Compare the recorded local result #
Captured from this package’s demo command on October 5, 2026. These results use synthetic fixtures and simulated delivery; they do not measure a live provider or inbox placement.
{
"simulated": true,
"consumeOnGet": {
"scanner": "consumed",
"person": "used"
},
"confirmation": {
"scanner": "confirmation required",
"person": "consumed",
"reuse": "used"
},
"delayed": "expired"
}What the local experiment establishes #
In the consume-on-GET flow, the first simulated scanner visit succeeds and the later person sees “used.” In the confirmation flow, that same GET leaves the token intact; the subsequent POST consumes it, and another POST fails. At the expiry boundary, consumption is rejected.
This does not prove that every scanner only performs GET requests. A scanner can behave differently, including following richer interactions. A confirmation page reduces one failure path; it is not proof of a human and does not replace the rest of your authentication controls.
Match the symptom to evidence #
| Symptom | Evidence to collect | Likely next investigation |
|---|---|---|
| Expired on first use | Issue, acceptance, arrival, and expiry times | Delivery delay versus token lifetime. |
| Already used | First successful consumption timestamp | Other tabs, devices, retries, or automated visits. |
| Wrong destination | Final redirect and approved callback settings | Tracking rewrites and redirect allowlists. |
| Works once, fails concurrently | Token update transaction result | Atomic single-use enforcement. |
Build the confirmation flow around your auth service #
Keep GET side-effect free: display a confirmation screen without changing authentication state. On an explicit, protected submission, validate the token, approved redirect, expiry, and session context; atomically mark the token consumed as part of the real operation. Two concurrent requests must not both succeed.
Use appropriate CSRF defenses and bind the flow to a requesting session where your product supports that model. A form with no such protections is not the production implementation. Store hashes rather than plaintext bearer tokens, suppress token-bearing logs, and avoid third-party scripts on the confirmation page.
Give expired and used links a clear next action that requests a fresh link without disclosing whether an account exists. Do not silently extend an expired token. Reconcile delayed or uncertain email requests separately from authentication recovery.
Does a confirmation button stop every scanner? #
No. A scanner capable of submitting the form can still consume the token. A POST changes the consumption point; it does not prove a human is present. Production authentication also needs secure session handling, CSRF protection, atomic token storage, and a recovery path.
Use with your coding agent #
Download the example, then copy this brief into your coding agent. The same brief is included as BUILD-BRIEF.md.
# Why magic links expire before users click them — coding-agent brief
## Objective
Compare token consumption on GET with a confirmation step that consumes the token only on explicit POST.
## Read first
Read README.md, package.json, lab.mjs, test.mjs and demo.mjs. Keep dependency versions pinned to package-lock.json. Use Node.js 22+.
## Dependencies
Node.js built-ins only. Install with npm ci and preserve the lockfile.
## Work
Start at `tokenLab` in lab.mjs. Run the existing synthetic fixtures before changing behavior. Preserve the guide's original scenario and add a regression check for each changed failure case.
## Constraints
All email is simulated. Do not add provider credentials, send email, deploy services, or use the application's database. Preserve unknown outcomes instead of claiming delivery. Do not turn the local demonstration into production authentication or a public mail relay. Explain the production setup separately.
## Verification
Run `npm ci`, `npm test`, and `npm run demo`.
## Acceptance
Prove scanner GET, expiry at the exact boundary, one-use behavior, invalid input, capacity limits and competing confirmations. Keep the output labelled as a token-lifecycle simulation.
No real tokens, signing secrets, or recipient data appear in logs. All fixtures use synthetic values.Check the download #
The ZIP contains 5,591 bytes. Compare its SHA-256 digest with this value before extracting. Downloads are free and require no signup.
8ffaae8839958c93494d20080ec94f16259d5f44a95a67199712233593160572Sources and further reading
- Supabase: email templates and email prefetching
- OWASP: forgot password guidance
- RFC 9110: safe request methods
Written for Stampwing with AI assistance and checked against the linked documentation. Examples are educational; simulated results are labelled. How these resources are maintained.