---
title: "QR-pairing Login Flow"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-03
related: [auth-and-sessions.md](./auth-and-sessions.md)
---

## QR-pairing flow (M10)

Password-loze inlog op een (publieke) desktop. Mobiel — al ingelogd — bevestigt
door een QR te scannen die de desktop toont. Geen wachtwoord op het publieke
toetsenbord, geen credentials op de draad, demo-accounts geblokkeerd, paired-
sessie heeft eigen kortere TTL (8 u) + `paired`-vlag.

### Sequence

```mermaid
sequenceDiagram
  participant D as Desktop (anon)
  participant S as Server
  participant M as Mobiel (ingelogd)

  D->>S: POST /api/auth/pair/start
  S->>S: maak LoginPairing { secret_hash, desktop_token_hash, status=pending, expires=+5min }
  S-->>D: 200 { pairingId, mobileSecret, qrUrl }<br/>Set-Cookie: s4m_pair=desktopToken
  D->>D: render QR met qrUrl (#id=…&s=mobileSecret)
  D->>S: GET /api/auth/pair/stream/[pairingId]<br/>Cookie: s4m_pair
  S->>S: LISTEN scrum4me_pairing
  S-->>D: event: state { status: 'pending' }

  Note over M: Gebruiker scant QR
  M->>M: location.hash → mobileSecret
  M->>S: getPairingForApproval(pairingId, mobileSecret)
  S-->>M: { desktop_ua, desktop_ip, username }
  M->>M: toont bevestigingskaart
  Note over M: Tap "Bevestig"
  M->>S: approvePairing(pairingId, mobileSecret)
  S->>S: status pending→approved, expires +5min<br/>pg_notify scrum4me_pairing
  S-->>D: data { status: 'approved' }

  D->>S: POST /api/auth/pair/claim<br/>Cookie: s4m_pair, body: { pairingId }
  S->>S: atomic UPDATE WHERE status=approved AND token-hash<br/>→ status=consumed
  S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt }
  S-->>D: 200, Set-Cookie: session<br/>+ s4m_pair cleared
  D->>D: redirect /dashboard
```

### Threat-model

| Aanval | Mitigatie |
|---|---|
| **Replay** van een geconsumeerde pairing | Atomic `updateMany WHERE status='approved'` — concurrent dubbele claim ziet count=0 → 410 |
| **Phishing-QR** ingesloten op een vreemde site | Mobiele bevestigingspagina toont desktop-UA + IP; gebruiker moet expliciet tappen; waarschuwing onder de kaart |
| **Demo-account misbruik** | `approvePairing` early-return op `session.isDemo` — pairing blijft `pending` |
| **Brute-force** van pairings | Rate-limit 10 starts per IP per minuut; `pairingId` is CUID (lange entropy) |
| **Secret-leak via DB-dump** | DB bevat alleen sha256-hashes; plaintext geheimen verlaten desktop alleen via QR-fragment + POST-body (mobile) of HttpOnly cookie (desktop) |
| **Long-lived sessie op publieke desktop** | Paired-sessie krijgt 8u TTL i.p.v. reguliere; `paired: true` markeert 'm voor toekomstige remote-revoke |

### TTL-rationale

- **Pending: 5 min.** Genoeg voor menselijke handeling (telefoon pakken, scannen, bevestigen) — kort genoeg dat een verloren QR een klein attack-window heeft.
- **Approved (na bump): nogmaals 5 min.** Klant claim moet binnen redelijke tijd plaatsvinden; voorkomt dat een approved-maar-onclaimed pairing eindeloos open blijft.
- **Paired-sessie: 8 uur.** Korter dan de reguliere wachtwoord-sessie omdat de use-case publieke apparaten zijn waar je niet wil dat de sessie 's nachts blijft hangen.

### Waarom geen secret in URL

Servers loggen URL-paden en querystrings standaard — `nginx`, Vercel access
logs, observability-stacks (Sentry, Datadog), reverse proxies, CDN's. Een
geheim in `?s=…` belandt onbedoeld in al die logs. Twee technieken voorkomen dit:

1. **URL-fragment voor `mobileSecret`.** Het deel achter de `#` wordt door
   browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client
   Component leest `window.location.hash` en POST't de waarde in een body —
   ook niet in een URL.
2. **HttpOnly cookie voor `desktopToken`.** Cookie-headers worden meestal NIET
   in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendien
   `Path=/api/auth/pair`-scoped, dus verlaat die route nooit.

Twee gescheiden hashes (`secret_hash` voor mobiel-bewijs, `desktop_token_hash`
voor desktop-bewijs) zorgen dat een ene server-side compromis niet automatisch
de andere kant compromitteert.

Dit patroon is herbruikbaar — zie `docs/patterns/qr-login.md`.

---

