Login path — threat model
Where the email + password + TOTP login can be attacked, and which control stops each attack.
Assumptions
The login path is the one request every attacker sends first. It takes a secret from the user, compares it to a stored hash, and mints a session that grants every later request. Each of those three steps has its own attack, so the controls sit at three different layers: the edge, the Auth API, and the stores.
The login request
The Auth API returns the same 401 for an unknown email, a wrong password, and a locked account. That single message is the cheapest control in the doc, and the timing work in the threat table exists to protect it.
Attack surface
Three trust boundaries, two of them crossed by the password in clear text inside TLS. The Auth API is the only process that sees the password, so most threats land on it or on the two stores it writes.
Login — STRIDE
| ID | STRIDE | Target | Threat | Mitigation | Severity | Status |
|---|---|---|---|---|---|---|
| T1 | S | Browser | Credential stuffing with leaked email + password lists | Edge rate limit 10/min per IP and 5/h per email; breached-password check on login | high | mitigated |
| T2 | S | Browser | Phishing page relays password and TOTP code in real time | None today; passkeys planned for Q1 | high | accepted |
| T3 | S | Auth API | Brute force of the 6-digit TOTP code | 5 code attempts per pending login, then the login is discarded | high | mitigated |
| T4 | T | CDN + WAF | Spoofed X-Forwarded-For dodges the per-IP limit | Auth API reads only the CDN-signed client IP header and drops the rest | medium | mitigated |
| T5 | T | Session store (Redis) | Another service on the network rewrites the sid -> user_id map | Redis ACL user scoped to the Auth API; network policy allows only Auth | medium | open |
| T6 | R | Auth API | User denies a login from a device they did not use | Login audit row (time, IP, UA); email on first login from a new device | medium | mitigated |
| T7 | I | Auth API | Response time reveals whether the email exists | Run Argon2id against a fixed dummy hash when no row is found | medium | open |
| T8 | I | Users DB (Postgres) | Hash column read through SQL injection or a leaked backup | Parameterised queries; Argon2id m=64 MiB t=3; backups encrypted at rest | high | mitigated |
| T9 | D | CDN + WAF | Login flood exhausts Argon2id CPU on the Auth API | WAF challenge above 500 req/s on /login; hashing worker pool capped at 32 | high | mitigated |
| T10 | E | Session store (Redis) | Session fixation: attacker plants a sid before the victim logs in | New sid on every successful login; the pre-login sid is deleted | high | mitigated |
| T11 | E | Browser | Session cookie stolen through XSS | HttpOnly + Secure + SameSite=Lax cookie; CSP with no inline scripts | high | mitigated |
Controls and their proof
Every control in the threat table has one place where it is enforced and one test that fails when it is removed. A control without a test is a claim, not a control.
Login controls
| Control | Enforced at | Setting | Proven by |
|---|---|---|---|
| Per-IP and per-email rate limit | CDN + WAF | 10/min per IP; 5/h per email | load test login-ratelimit.spec |
| Signed client IP | Auth API | Trust only cdn-client-ip header; drop X-Forwarded-For | unit test on header parsing |
| Argon2id hashing | Auth API | m=64 MiB, t=3, p=1 | unit test: one hash takes 200-300 ms |
| Generic failure and constant-time path | Auth API | 401 Invalid email or password; dummy hash on unknown email | timing test: p95 delta under 5 ms (not yet written) |
| Account lockout | Auth API + Users DB | 10 failures in 15 min sets lock_until | integration test |
| TOTP attempt cap | Auth API | 5 codes per pending login | integration test |
| Session rotation | Auth API + Session store | new sid on login; old sid deleted | integration test |
| Cookie flags | Auth API | HttpOnly; Secure; SameSite=Lax; Path=/ | e2e header assert |
| Content Security Policy | CDN + WAF | default-src 'self'; no inline script | e2e header assert |
| WAF challenge | CDN + WAF | above 500 req/s on /login | staging load test |
| New-device email | Auth API | on first login from an unseen IP + UA pair | integration test |
Two controls in the threat table have no test yet: the timing path (T7) and the Redis ACL (T5). Both are open.
What remains
Two threats stay open and one is accepted. Phishing with real-time TOTP relay is the largest gap, and no password-based control closes it; passkeys do.
Residual risk
Mitigation: Ship passkeys as the default second factor in Q1.
Mitigation: Scope the Redis ACL to the Auth API and add the network policy.
Mitigation: Dummy-hash path plus the p95 timing test.