De setting
Nauteda is een B2B-handelsportaal voor partners van een Andalusische olijfolieproducent. Login werkt via magic-link met PKCE — geen wachtwoorden, geen reset-flows. Voor <500 partners is dat de juiste keuze.
Twee dagen voor de soft-launch testte een Italiaanse partner het inloggen vanuit een hotel in Madrid. Hij klikte op de magic-link in zijn Outlook. Hij belandde op een witte pagina met de error:
PKCE code verifier missingGeen rage tegen de UI. Geen "het werkt niet". Gewoon: een witte pagina. Voor een soft-launch is dat een blocker.
Stap 1 — de eenvoudige hypothese
Mijn eerste aanname was de gebruikelijke: een cookie die niet goed wordt gezet vanwege de domein-wissel tijdens redirect. Dus ik testte op productie met een Chrome-private-window. Werkt. Dan met een verse Firefox. Werkt. Dan met een verse Safari op iOS. Werkt.
Wat ik niet getest had: corporate Outlook op Windows.
Stap 2 — wat Outlook eigenlijk doet
Wanneer Microsoft Exchange Online een mail ontvangt, gaat elke link door Safe Links: een service die de URL eerst pre-scant. Het idee is goedbedoeld — je opent geen phishing-pagina meer.
Het mechanisme: Microsoft vervangt jouw https://nauteda.com/auth/callback?token=abc&state=xyz met een wrapper-URL die ongeveer zo loopt:
https://emea01.safelinks.protection.outlook.com/?url=
https%3A%2F%2Fnauteda.com%2Fauth%2Fcallback%3Ftoken%3Dabc...
&data=...&sdata=...Maar Safe Links pre-scant óók de URL door 'm zelf één keer op te halen. Dat betekent: een GET request naar jouw redirect-URL, zonder de juiste cookies, zonder user-agent context. Resultaat:
- De pre-scan triggert je auth-flow.
- De auth-flow valideert de PKCE-code-verifier tegen de challenge.
- De code-verifier zit in een cookie die alleen op de echte gebruikers-sessie staat — niet bij Microsoft's pre-scan.
- PKCE faalt. De single-use token wordt geinvalideerd.
- Wanneer de partner zelf later klikt: token is dood. Witte pagina.
Microsoft "veiligheid" doodt je auth.
Stap 3 — drie kandidaat-fixes
Drie mogelijke routes:
A. Cookie-loos PKCE
Sla de code-verifier op in de URL zelf in plaats van in een cookie. Veiligheidsprobleem: de verifier verschijnt dan in server-logs, browser-history, en — heel ironisch — in Microsoft's Safe Links wrapper. Niet doen.
B. Disable PKCE
Mogelijk, want de threat-model van een eenmalige magic-link is laag. Maar dan los je dit probleem op door auth-zwakker te maken. Geen optie voor een B2B-handelportaal.
C. Server-side verifier-cache
Sla de PKCE-verifier op in Redis (of database) gekoppeld aan een short-lived random nonce. Stuur de nonce mee in de magic-link in plaats van de token zelf. De pre-scan kan de nonce-resolve triggeren — maar het token wordt pas gebrand op de eerste echte GET met juiste user-agent en client-hints.
C is de juiste keuze.
Stap 4 — de implementatie
Twee aanpassingen:
// Magic-link verzenden
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
// ...
}De magie zit in twee dingen:
Sec-Fetch-User: ?1wordt alleen gestuurd door echte browser-navigatie via klik. Headless bots, link-scanners, pre-fetchers sturen het niet. Dat is je discriminator.- Atomic consume zorgt dat zelfs als Microsoft 100x pre-scant en de gebruiker daarna 5x op refresh klikt, het token slechts één keer brandt.
Stap 5 — testen
Voor je live durft te gaan: stuur de mail naar jezelf op een corporate Microsoft 365-account. Open via Outlook web, Outlook desktop, en de mobile app. Wacht 30 seconden. Klik dan. Werkt?
Zo niet, check je logs voor de pre-scan-detectie. Soms moet je een ander Sec-Fetch-* header gebruiken (Sec-Fetch-Mode op navigate). Microsoft's headers evolueren.
Wat ik leerde
Test in corporate Outlook. Niet Gmail. Niet Hey. Niet je eigen Fastmail. De grootste fractie van je B2B-doelgroep zit in Exchange, en Microsoft's "security" is een eigen ecosysteem dat zich anders gedraagt dan elke andere mail-client.
Magic-links zijn een mooi UX-patroon, maar ze gaan ervan uit dat een link in een mail ongeschonden aankomt. Die aanname klopt voor consumenten. Niet voor enterprise. Bouw je auth zo dat hij meerdere GET-requests op dezelfde URL niet uit elkaar trekt.
Bot-detectie via fetch-metadata is je vriend. Sec-Fetch-User, Sec-Fetch-Mode, Sec-Fetch-Site — drie headers die je heel veel onnodige edge-case-headache besparen, omdat ze het verschil tussen echt en geautomatiseerd vrij betrouwbaar markeren.
Nauteda is daarna soft-gelaunched zonder verdere PKCE-incidenten.