emailverifier.dev
Posts

Email Link Scanners: Stop GET Requests From Confirming Accounts

| 6 min read | Usama Ejaz
An email link splits into two paths: an automated scanner stops at a read-only boundary while a deliberate human action passes through a confirmation gate.

I ran one confirmation token through four requests:

4/4 checks passed
scanner GET changed state: no
human POST changed state: yes
replay POST status: 410

That first line is the design target. Email security software can fetch a link before the recipient acts. If a plain GET consumes the token, your application can confirm an address, activate an account, or create a login session for a machine that only inspected a URL.

The reliable fix is not to guess whether a request came from a scanner. Keep GET read-only, show a small confirmation page, and put the irreversible action behind an explicit POST. The token should remain expiring, purpose-bound, and single-use.

The failure is one state-changing GET

RFC 8058 documents the underlying behavior: anti-spam software often fetches resources in email headers automatically, and a sender cannot mechanically distinguish that request from a person’s request. The RFC solves the unsubscribe version of the problem with a defined POST protocol. It discusses header URLs specifically; the same engineering consequence applies to any capability URL an automated system chooses to fetch.

The same failure shape appears when an application puts a capability token in an ordinary email link:

GET /confirm-email/opaque-token
  -> mark user.email_confirmed = true
  -> consume token
  -> redirect to dashboard

A crawler does not need to understand your application. It only needs to follow the URL. By the time the person opens the message, the token may already be consumed. Worse, your audit trail can claim that the inbox challenge succeeded even though no person deliberately completed it.

This is also an HTTP semantics bug. RFC 9110 defines GET as a safe method whose requested semantics are essentially read-only. That distinction exists partly so automated retrieval and prefetching can operate without causing harm.

Why not detect the scanner?

User-Agent strings, IP ranges, request timing, and header fingerprints can be useful telemetry. They are weak authorization inputs.

A security service can change its network or request shape. A human can arrive through the same corporate proxy. Some systems fetch at delivery time, some inspect at click time, and some pass the request through a rewritten URL. A list of known scanner signatures will always be incomplete.

The stronger boundary is method semantics: every GET is harmless, regardless of who sent it. You can record that a link was viewed, subject to your privacy policy, but do not convert the view into proof of confirmation or a logged-in session.

Move the commit behind POST

The implementation I would ship uses two stages:

email link
  -> GET /confirm-email/:token
       validate token, render Continue, change nothing
  -> POST /confirm-email
       validate token again, consume once, commit confirmation

The GET response should avoid leaking the token through caches or outbound requests. Send Cache-Control: no-store and Referrer-Policy: no-referrer, keep third-party assets off the page, and never put the full URL in logs. RFC 9111 defines the response-side no-store directive, while OWASP recommends a no-referrer policy for pages reached through emailed URL tokens.

Here is the core of the fixture I executed. The real application needs a transactional database update, but the state boundary is the same:

function readChallenge(token) {
  const challenge = challenges.get(sha256(token));

  if (!challenge || challenge.expiresAt <= Date.now() || challenge.consumedAt) {
    return { status: 410, state: "unavailable", changed: false };
  }

  return {
    status: 200,
    state: "awaiting_confirmation",
    changed: false,
    headers: {
      "cache-control": "no-store",
      "referrer-policy": "no-referrer"
    }
  };
}

function confirmChallenge(token, purpose) {
  const challenge = challenges.get(sha256(token));

  if (!challenge || challenge.expiresAt <= Date.now() || challenge.consumedAt) {
    return { status: 410, state: "unavailable", changed: false };
  }
  if (purpose !== challenge.purpose) {
    return { status: 400, state: "wrong_purpose", changed: false };
  }

  challenge.consumedAt = new Date().toISOString();
  return { status: 204, state: "confirmed", changed: true };
}

The POST must validate the same properties again. A successful GET does not reserve the token. Bind the stored challenge to one subject and one purpose, then consume it with a conditional update that can win only once.

Our secure verification-link guide owns the token details: secure randomness, hashed storage, expiry, and atomic redemption. The scanner-safe change is narrower. It moves redemption from link retrieval to a deliberate unsafe method.

Does the extra click create unnecessary friction?

Yes, it adds one action. That is the strongest objection to this pattern.

The right question is what your application intends the link to prove. If confirmation changes account state or creates an authenticated session, a scanner fetch is not enough evidence. A short page with one clear button makes the action explicit and preserves the meaning of the audit event.

Password-reset links already fit this model well. GET opens the reset form; POST changes the password. For magic-link login, GET can open a page that names the requested action without revealing account state, and POST can create the restricted session. Sensitive operations may also require re-authentication or another factor.

One-click unsubscribe is the intentional exception. A receiving system sends the standardized POST only after the mailbox user requests it. Do not put an extra confirmation page in that protocol. The List-Unsubscribe implementation guide explains the exact headers, POST body, and idempotent suppression write.

Where does email verification fit?

Address verification and link confirmation answer different questions. A verification check can evaluate syntax, mail routing, provider classification, risk signals, and available mailbox evidence before you send a message. It cannot tell whether a person deliberately completed the action behind your link.

Keep those records separate. The emailverifier.dev API returns deliverable, risky, undeliverable, or unknown, with an allow, review, or block action. None of those statuses should set email_confirmed_at. Our validation, verification, and confirmation guide explains the boundary.

Test the deployed boundary, not the button

Create a challenge for a test account you control. Fetch the emailed URL with curl or an HTTP client and confirm that the account remains pending. Repeat with HEAD if your route supports it. Then open the page normally, submit the POST once, and verify that exactly one state transition and one audit event exist. A second POST should produce a stable already-used or unavailable result without repeating the action.

Check the response headers and application logs during the same run. The confirmation page should return no-store and no-referrer, and neither the raw token nor the full confirmation URL should appear in logs. OWASP’s email guidance explicitly warns against logging verification tokens or full verification URLs.

When the scanner request and the human page view both leave the account pending, and only the explicit POST commits once, the confirmation event means what your system says it means.

Continue reading