Skip to content
Engineering

Why magic links expire before users click them

Reproduce an expired-link report and identify whether a scanner, reuse or elapsed time consumed the token.

THE SHORT ANSWER

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.

Run from the extracted example foldersh
npm ci
npm test
npm run demo

Read 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.

Local simulation · lab.mjsjavascript
    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.

FilePurpose
lab.mjsThe example behavior shown in this article.
test.mjsAcceptance checks and synthetic failure cases.
demo.mjs / expected-output.jsonA repeatable local experiment and its recorded result.
README.mdSetup commands, expected behavior and production boundaries.
BUILD-BRIEF.mdThe coding-agent brief below.
package.json / package-lock.jsonPinned dependencies and runnable commands.
LICENSEMIT 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.

Recorded local simulation · expected-output.jsonjson
{
  "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 #

SymptomEvidence to collectLikely next investigation
Expired on first useIssue, acceptance, arrival, and expiry timesDelivery delay versus token lifetime.
Already usedFirst successful consumption timestampOther tabs, devices, retries, or automated visits.
Wrong destinationFinal redirect and approved callback settingsTracking rewrites and redirect allowlists.
Works once, fails concurrentlyToken update transaction resultAtomic 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.

Review password reset copy and security boundaries →

Trace a missing or late email →

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.

Coding-agent build briefmarkdown
# 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.

SHA-256 · magic-link-expired-before-click.ziptext
8ffaae8839958c93494d20080ec94f16259d5f44a95a67199712233593160572

Sources and further reading

Written for Stampwing with AI assistance and checked against the linked documentation. Examples are educational; simulated results are labelled. How these resources are maintained.