---
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 }
Set-Cookie: s4m_pair=desktopToken
D->>D: render QR met qrUrl (#id=…&s=mobileSecret)
D->>S: GET /api/auth/pair/stream/[pairingId]
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
pg_notify scrum4me_pairing
S-->>D: data { status: 'approved' }
D->>S: POST /api/auth/pair/claim
Cookie: s4m_pair, body: { pairingId }
S->>S: atomic UPDATE WHERE status=approved AND token-hash
→ status=consumed
S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt }
S-->>D: 200, Set-Cookie: session
+ 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`.
---