The setting
Nauteda is a B2B trading portal for partners of an Andalusian olive-oil producer. Login uses magic-link with PKCE — no passwords, no reset flows. For <500 partners that's the right call.
Two days before soft launch, an Italian partner tested sign-in from a hotel in Madrid. He clicked the magic link in his Outlook. He landed on a blank page with the error:
PKCE code verifier missingNo UI rage. No "it doesn't work." Just: a blank page. For a soft launch that's a blocker.
Step 1 — the easy hypothesis
My first guess was the usual: a cookie not setting properly after a domain switch during redirect. So I tested on production with a Chrome private window. Works. Then a fresh Firefox. Works. Then a fresh Safari on iOS. Works.
What I hadn't tested: corporate Outlook on Windows.
Step 2 — what Outlook actually does
When Microsoft Exchange Online receives a mail, every link goes through Safe Links: a service that pre-scans the URL. The intent is good — you don't open a phishing page anymore.
The mechanism: Microsoft rewrites your https://nauteda.com/auth/callback?token=abc&state=xyz with a wrapper URL roughly like:
https://emea01.safelinks.protection.outlook.com/?url=
https%3A%2F%2Fnauteda.com%2Fauth%2Fcallback%3Ftoken%3Dabc...
&data=...&sdata=...But Safe Links also pre-scans the URL by fetching it itself once. That means: a GET request to your redirect URL, without the right cookies, without user-agent context. Result:
- The pre-scan triggers your auth flow.
- The auth flow validates the PKCE code verifier against the challenge.
- The code verifier lives in a cookie tied to the real user session — not Microsoft's pre-scan.
- PKCE fails. The single-use token gets invalidated.
- When the partner clicks for real later: token's dead. Blank page.
Microsoft "security" kills your auth.
Step 3 — three candidate fixes
Three possible routes:
A. Cookie-less PKCE
Store the code verifier in the URL itself instead of in a cookie. Security problem: the verifier then shows up in server logs, browser history, and — quite ironically — inside Microsoft's Safe Links wrapper. Don't.
B. Disable PKCE
Possible, since the threat model of a one-time magic link is low. But then you solve this problem by weakening auth. Not an option for a B2B trading portal.
C. Server-side verifier cache
Store the PKCE verifier in Redis (or database) keyed by a short-lived random nonce. Send the nonce in the magic link instead of the token itself. The pre-scan can trigger nonce resolution — but the token only gets burned on the first real GET with proper user-agent and client hints.
C is the right call.
Step 4 — the implementation
Two changes:
// Sending the magic link
const nonce = crypto.randomUUID();
await redis.set(`auth:${nonce}`, JSON.stringify({
verifier,
email,
expiresAt: Date.now() + 15 * 60_000,
consumed: false,
}), { ex: 900 });
const url = `${BASE_URL}/auth/callback?n=${nonce}`;
await resend.emails.send({ to: email, html: render(url) });// /auth/callback route — handle pre-scan vs. real visit
export async function GET(req: Request) {
const nonce = new URL(req.url).searchParams.get("n");
if (!nonce) return notFound();
// 1. Detect Safe Links pre-scan (no Sec-Fetch-User header on bot traversal)
const isRealClick = req.headers.get("sec-fetch-user") === "?1";
if (!isRealClick) {
// Bot/pre-scan: respond 200 OK with empty body. Do NOT consume.
return new Response("OK", { status: 200 });
}
// 2. Real click — atomic consume
const data = await redis.get(`auth:${nonce}`);
if (!data || data.consumed) return redirect("/auth/expired");
await redis.set(`auth:${nonce}`, { ...data, consumed: true });
// 3. PKCE-validate and sign in
// ...
}The magic is in two things:
Sec-Fetch-User: ?1is only sent by real browser navigation from a click. Headless bots, link scanners, pre-fetchers don't send it. That's your discriminator.- Atomic consume ensures that even if Microsoft pre-scans 100 times and the user then refreshes 5 times, the token burns exactly once.
Step 5 — testing
Before you ship: send the email to yourself on a corporate Microsoft 365 account. Open via Outlook web, Outlook desktop, and the mobile app. Wait 30 seconds. Then click. Works?
If not, check your logs for pre-scan detection. Sometimes you need a different Sec-Fetch-* header (Sec-Fetch-Mode on navigate). Microsoft's headers evolve.
What I learned
Test in corporate Outlook. Not Gmail. Not Hey. Not your own Fastmail. The largest slice of your B2B audience lives in Exchange, and Microsoft's "security" is its own ecosystem that behaves differently than any other mail client.
Magic links are a nice UX pattern, but they assume the link in a mail arrives untouched. That assumption holds for consumers. Not for enterprise. Build your auth so it doesn't conflate multiple GET requests on the same URL.
Bot detection via fetch-metadata is your friend. Sec-Fetch-User, Sec-Fetch-Mode, Sec-Fetch-Site — three headers that save you a lot of unnecessary edge-case headache, because they mark the difference between real and automated traversal pretty reliably.
Nauteda soft-launched after this without further PKCE incidents.