Ga naar inhoud
Thomas Foundry
← LogboekCase fact

Hoe Nauteda's partner-portaal Outlook brak

Magic links + Outlook's bot-scanners = pijn. De PKCE-fix, stap voor stap uitgelegd.

·6 min lezen

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 missing

Geen 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:

  1. De pre-scan triggert je auth-flow.
  2. De auth-flow valideert de PKCE-code-verifier tegen de challenge.
  3. De code-verifier zit in een cookie die alleen op de echte gebruikers-sessie staat — niet bij Microsoft's pre-scan.
  4. PKCE faalt. De single-use token wordt geinvalideerd.
  5. Wanneer de partner zelf later klikt: token is dood. Witte pagina.

Microsoft "veiligheid" doodt je auth.

Stap 3 — drie kandidaat-fixes

Drie mogelijke routes:

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:

  1. Sec-Fetch-User: ?1 wordt alleen gestuurd door echte browser-navigatie via klik. Headless bots, link-scanners, pre-fetchers sturen het niet. Dat is je discriminator.
  2. 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.