If a fresh magic link says "expired" on the first click, an email scanner probably clicked it first. Microsoft Defender, Proofpoint and Mimecast open links in corporate mail before the recipient does, and when your verify endpoint spends the one-time token on a plain GET, the scanner gets the login and your user gets an error.
The fix isn't an IP blocklist. It's a confirmation step: the link opens a page, and only a button press, sent as a POST, spends the token. IP and ASN data still earn a place, as evidence. They show which network clicked first, so you can prove the problem and measure it. They should never decide who gets in.
TL;DR
- Corporate email gateways (Microsoft Defender Safe Links, Proofpoint, Mimecast) fetch links before or during delivery. If a GET spends your magic link token, the gateway spends it.
- The fix: GET renders a confirmation page, and only a POST from a real button press consumes the token, in one atomic update. No auto-submit, because some scanners run JavaScript.
- ASN data tells you who clicked first. Proofpoint, Mimecast and Barracuda register their networks as
BUSINESS, notHOSTING, so match them by AS number or company name. - Never let the IP check gate the login. People behind Zscaler, remote browser isolation or Windows 365 arrive from cloud addresses too.
- Log every visit with its network label and link age, then measure the pre-click rate per recipient domain. In short: move token consumption from GET to a POST behind a button, and treat IP intelligence as a logging tool. The confirmation page stops scanners that fetch and render; browser binding handles the ones that click. The ASN labels prove scanners were the problem and show which customer domains to talk to.
What actually clicks the link
Corporate mail passes through a secure email gateway before it reaches an inbox, and the major gateways run an email security scanner that fetches URLs on its own schedule. Microsoft's Safe Links documentation says URLs "are scanned prior to message delivery, regardless of whether the URLs are rewritten or not," and that URLs without a valid reputation "are detonated asynchronously in the background." Its recommended policy holds the message until that scan finishes.
Mimecast went the same way. Its URL Pre-Delivery Action scans suspicious links before delivery, and Mimecast scheduled it to turn on with the Hold setting for every customer on November 18, 2025. Proofpoint holds and sandboxes suspicious URLs before delivery, then rewrites links for a second check at click time.
None of these vendors says "we click your magic links." My read is that a one-time sign-in URL is the textbook detonation candidate: it's unique and has never been seen before, so it has no reputation. That fits the "magic link not working in Outlook" reports, too. In one long NextAuth thread, a developer describes it hitting "a lot of my corporate clients who rely on outlook," where company policy mandates Safe Links.
What it looks like in your logs
Before the fix, the sequence is short. The link goes out, a GET arrives from a gateway network a couple of seconds later and spends the token, and four minutes after that the real person gets a "magic link expired" page. After the fix, the scanner still shows up. It just can't do any damage:
14:02:11 link sent recipient_domain=acme.example 14:02:13 view AS8075 Microsoft Corporation label=cloud age_sec=2 14:06:40 view AS7922 Comcast Cable Communications, LLC label=other age_sec=269 14:06:43 redeem AS7922 Comcast Cable Communications, LLC label=other age_sec=272 The timestamps are illustrative; the pattern isn't. A view from a cloud or security-vendor network within seconds of sending, then a second view from an ordinary ISP. For reference, 52.112.103.113, an address a Microsoft moderator attributed to automated Microsoft scanning, resolves to AS8075 exactly like the 14:02:13 line.
Fixing the magic link email scanner problem
HTTP already has the rule your verify endpoint broke. GET is a safe method, and MDN's definition notes that browsers can call safe methods "without fearing to cause any harm to the server," which is what makes prefetching possible. Email link prefetching by a security gateway is the same idea at a different layer. A GET that marks a token as used is a side effect hiding behind a safe method, and scanners are entitled to trigger it.
So split the endpoint. GET looks the token up and renders a page with one button. POST, sent by that button, spends the token in a single atomic update. A scanner fetches the page, finds a form, and leaves. The user clicks and is in.
Express: render on GET, spend on POST
The GET handler reads, renders and logs. It never writes to magic_links:
// verify-routes.js import crypto from 'node:crypto'; import express from 'express'; import pg from 'pg'; import { recordVisit } from './scanner-log.js'; import { createSession } from './session.js'; // your existing session helper // Table: magic_links(id, user_id, email, token_hash, created_at, expires_at, used_at) const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); const APP_ORIGIN = process.env.APP_ORIGIN; // e.g. https://app.example.com const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/; // 32 random bytes, base64url-encoded const hash = (t) => crypto.createHash('sha256').update(t).digest('hex'); const expiredPage = () => '<p>This link has expired. <a href="/login">Send a new one</a>.</p>'; const LINK_COLS = `id, user_id, created_at, split_part(email, '@', 2) AS recipient_domain`; export const router = express.Router(); // Scanners, link previews and the real user all land here, so it never spends the token. router.get('/verify', async (req, res) => { // same-origin, not no-referrer: under no-referrer, browsers send // "Origin: null" on the form POST and the origin check fails. res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'same-origin' }); const token = String(req.query.token ?? ''); if (!TOKEN_RE.test(token)) return res.status(400).send('Invalid link.'); try { const { rows } = await pool.query( `SELECT ${LINK_COLS} FROM magic_links WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`, [hash(token)]); void recordVisit(req, rows[0], 'view'); // logging only, never awaited if (!rows[0]) return res.status(410).send(expiredPage()); // A plain form. Nothing submits it automatically. res.send(`<form method="post" action="/verify"> <input type="hidden" name="token" value="${token}"> <button type="submit">Sign in</button></form>`); } catch (err) { console.error('GET /verify failed:', err); res.status(500).send('Something went wrong. Please request a new link.'); } }); The token regex does double duty: it rejects junk before a database round trip, and it guarantees the value is safe to drop into the hidden field. The POST handler is where the token actually dies:
router.post('/verify', express.urlencoded({ extended: false }), async (req, res) => { // Another site can't post a token into your user's browser (login CSRF). if (req.get('origin') !== APP_ORIGIN) return res.status(403).end(); const token = String(req.body?.token ?? ''); if (!TOKEN_RE.test(token)) return res.status(400).send('Invalid link.'); try { // One UPDATE picks the winner, so a double-click can't spend it twice. const { rows } = await pool.query( `UPDATE magic_links SET used_at = now() WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now() RETURNING ${LINK_COLS}`, [hash(token)]); void recordVisit(req, rows[0], rows[0] ? 'redeem' : 'redeem_failed'); if (!rows[0]) return res.status(410).send(expiredPage()); await createSession(res, rows[0].user_id); res.redirect(303, '/'); } catch (err) { console.error('POST /verify failed:', err); res.status(500).send('Something went wrong. Please request a new link.'); } }); UPDATE ... WHERE used_at IS NULL RETURNING is the important line: two concurrent POSTs can't both win, whether the second one is a double-click or a scanner that does press buttons. recordVisit isn't awaited, so a slow IP lookup never delays a sign-in.
Don't auto-submit the form
It's tempting to keep the one-click feel by submitting that form with a line of JavaScript on page load. Don't. Back in 2019, the Healthchecks.io team found that Office 365's link scanning was loading URLs "in a full-blown browser, and the browser executes JS," and it was unsubscribing their users through exactly that kind of auto-submitting page.
The button press is the whole point. A plain fetch, or a sandbox that only renders the page, can't spend the token. Scanners that also press buttons exist, and the last section deals with them.
Where ASN data comes in: proving a scanner clicked first
Once the fix ships, the scanner's visit stops costing you anything. You still want to see it, for two reasons: to confirm that the old "expired link" tickets really were scanners and not a clock bug, and to show a specific customer's IT team, with numbers, what their gateway is doing. The network behind each request is the cheapest way to get both.
The networks these vendors register
I looked up the gateway vendors' autonomous systems on November 10, 2026. The type column is the one that surprises people:
| ASN | Organization | type | Registry name |
|---|---|---|---|
| AS8075 | Microsoft Corporation | HOSTING | MICROSOFT-CORP-MSN-AS-BLOCK |
| AS22843 | Proofpoint, Inc. | BUSINESS | PROOFPOINT-ASN-US-EAST |
| AS26211 | Proofpoint, Inc. | BUSINESS | PROOFPOINT-ASN-US-WEST |
| AS52129 | Proofpoint, Inc. | BUSINESS | PROOFPOINT-ASN-EU |
| AS30031 | Mimecast North America Inc | BUSINESS | MIMECAST-US |
| AS15324 | Barracuda Networks, Inc. | BUSINESS | BARRACUDA-NETWORKS-INC |
| AS16509 | Amazon.com, Inc. | HOSTING | AMAZON-02 |
| AS15169 | Google LLC | HOSTING |
Only Microsoft's network, among the gateway vendors, is classified as hosting. Proofpoint, Mimecast and Barracuda register their own address space as ordinary businesses, so a rule that flags type === "HOSTING" catches Safe Links and misses the other three. Match those vendors by AS number or company name instead.
One caveat: these are the networks the vendors register, not a verified list of the addresses their sandboxes scan from. Treat the table as a starting list and grow it from your own logs.
When the ASN names the wrong company
ASN ownership tells you who announces a block, not who's using it. 209.222.82.10 sits in a Barracuda email security range, but Amazon announces the block. Look it up and asn.organization says "Amazon.com, Inc." with type: "HOSTING", while the company object says BARRACUDA NETWORKS, INC.
If you only stored the ASN, that request would land in your "AWS" bucket next to every scraper and cron job on EC2. The company field puts it in the right row.
A classifier that labels and never blocks
Any IP data source with ASN data covers the basics. IPGeolocation, ipinfo Lite and MaxMind's free GeoLite ASN database all return the AS number and organization, and that's enough for the vendor match. I'm using IPGeolocation's unified lookup with its IP Security API fields because one call also returns the company actually using the IP plus the cloud and corporate-gateway flags, and the next section depends on those. The trade-off: those fields need a paid plan, and the call costs three credits (one base, two for security). On the free plan, drop include=security, request fields=asn, and keep the ASN match.
// scanner-log.js: labels who opened a magic link. Output goes to logs, never into auth. const API = 'https://api.ipgeolocation.io/v3/ipgeo'; const TTL_MS = 24 * 60 * 60 * 1000; const cache = new Map(); // swap for Redis once you run more than one instance // Gateway vendors' own networks. Typed BUSINESS, so a HOSTING check misses them. const VENDOR_ASNS = new Map([ ['AS22843', 'Proofpoint'], ['AS26211', 'Proofpoint'], ['AS52129', 'Proofpoint'], ['AS30031', 'Mimecast'], ['AS15324', 'Barracuda'], ]); const VENDOR_COMPANIES = /proofpoint|mimecast|barracuda/i; // catches vendors on AWS async function lookup(ip) { const hit = cache.get(ip); if (hit && hit.expires > Date.now()) return hit.data; const url = `${API}?apiKey=${process.env.IPGEO_API_KEY}&ip=${encodeURIComponent(ip)}` + '&include=security&fields=asn,company,security'; const res = await fetch(url, { signal: AbortSignal.timeout(1500) }); if (!res.ok) throw new Error(`ipgeolocation lookup failed: HTTP ${res.status}`); const data = await res.json(); cache.set(ip, { data, expires: Date.now() + TTL_MS }); return data; } classify() turns that response into one label, and recordVisit() writes the log line. Same file:
export function classify(d) { if (d?.security?.is_corporate_gateway) return 'corporate_gateway'; // a person, via Zscaler etc. if (VENDOR_ASNS.has(d?.asn?.as_number ?? '')) return 'email_security_vendor'; if (VENDOR_COMPANIES.test(d?.company?.name ?? '')) return 'email_security_vendor'; if (d?.asn?.type === 'HOSTING' || d?.security?.is_cloud_provider) return 'cloud'; return 'other'; } export async function recordVisit(req, link, event) { const entry = { type: 'magic_link_visit', event, link_id: link?.id ?? null, recipient_domain: link?.recipient_domain ?? null, ip: req.ip, age_sec: link ? Math.round((Date.now() - new Date(link.created_at)) / 1000) : null, user_agent: req.get('user-agent') ?? '', }; try { const d = await lookup(req.ip); Object.assign(entry, { asn: d?.asn?.as_number, org: d?.asn?.organization, company: d?.company?.name, label: classify(d) }); } catch (err) { entry.label = 'lookup_failed'; // fail open: sign-in never waits on this entry.error = err.message; } console.log(JSON.stringify(entry)); } classify() checks the corporate-gateway flag first on purpose, because that's a person, not a robot. Microsoft's AS8075 isn't in the vendor map either: the same network carries Azure VMs and Cloud PCs, so it lands in cloud, and the order of visits on the link tells you the rest. Behind a load balancer, set app.set('trust proxy', 1) (or your real hop count) so req.ip is the client and not your proxy.
Here's what the lookup returns for the Microsoft address from the log above. The full response also carries location, currency and time zone; the fields filter drops them:
{ "ip": "52.112.103.113", "asn": { "as_number": "AS8075", "organization": "Microsoft Corporation", "country": "US", "type": "HOSTING", "domain": "microsoft.com", "date_allocated": "1997-03-31", "rir": "ARIN" }, "company": { "name": "Microsoft Corporation", "type": "HOSTING", "domain": "microsoft.com" }, "security": { "threat_score": 5, "is_tor": false, "is_proxy": false, "proxy_provider_names": [], "proxy_confidence_score": 0, "proxy_last_seen": "", "proxy_days_seen_30d": 0, "proxy_days_seen_7d": 0, "proxy_provider_count_30d": 0, "is_residential_proxy": false, "is_vpn": false, "vpn_provider_names": [], "vpn_confidence_score": 0, "vpn_last_seen": "", "is_relay": false, "relay_provider_name": "", "is_anonymous": false, "is_known_attacker": false, "is_bot": false, "bot_confidence_score": 0, "bot_operator_name": "", "bot_type": "", "is_known_good_bot": false, "bot_last_seen": "", "is_spam": false, "is_cloud_provider": true, "cloud_provider_name": "Microsoft Corporation", "is_corporate_gateway": false, "corporate_gateway_type": "", "corporate_gateway_provider_name": "" } } Note is_bot: false for this address. Don't expect a gateway scanner to identify itself, which is why the label leans on the network and not on bot flags or user agents.
Why the IP check must never decide who logs in
It's tempting to go one step further and refuse the POST when it comes from a cloud network. That's how you lock out real employees.
The is_corporate_gateway flag in that response is there for exactly this, because plenty of corporate browsing leaves through a vendor's cloud. Staff behind a secure web gateway like Zscaler reach you from Zscaler's addresses. With remote browser isolation (corporate_gateway_type: "browser_isolation"), the page is rendered in the vendor's cloud browser, so the click genuinely comes from a datacenter. Windows 365 adds another case: on Microsoft's hosted network, Microsoft manages the public IP that Cloud PC traffic leaves from. A person on a Cloud PC and Safe Links can both arrive from Microsoft.
Now compare the two mistakes. Miss a scanner after the fix and nothing happens: it saw a button and left. Block a real user and you've rebuilt the bug you just fixed, except now it's your code doing it. A scanner list is a thermometer, not a lock.
Measure the damage in your own logs
Ship those JSON lines into whatever SQL store you already use (Postgres, ClickHouse, BigQuery), and "is a scanner eating our links?" becomes a query instead of a hunch.
Pre-click rate by recipient domain
Timing alone won't separate scanners from people. Someone who just asked for a sign-in link is watching their inbox and clicks within seconds too. The stronger signature is order: a robot view first, then a view from somewhere else.
-- Postgres. Assumes the log lines landed in magic_link_visits(..., logged_at). WITH views AS ( SELECT link_id, recipient_domain, label, row_number() OVER (PARTITION BY link_id ORDER BY logged_at) AS n FROM magic_link_visits WHERE event = 'view' AND link_id IS NOT NULL ), per_link AS ( SELECT link_id, recipient_domain, bool_or(n = 1 AND label IN ('email_security_vendor', 'cloud')) AS robot_first, bool_or(n > 1 AND label NOT IN ('email_security_vendor', 'cloud')) AS human_later FROM views GROUP BY link_id, recipient_domain ) SELECT recipient_domain, count(*) AS links, roun...
