Skip to content
chiltepin

Generated from: “Threat-model the login path — where can it be attacked and what stops each attack?

Login path — threat model

Written by an agent from the skill, validated by chiltepin check, rendered by the renderer — shown as generated, 13 September 2026.

DOCUMENTDRAFT

Login path — threat model

Where the email + password + TOTP login can be attacked, and which control stops each attack.

SECTION 01 · Note

Assumptions

Note
The login is email + password with optional TOTP. The browser reaches the Auth API through a CDN with a WAF. Sessions are opaque ids in Redis, sent as a cookie. Password hashes are Argon2id in Postgres. Password reset and OAuth sign-in are separate paths and are out of scope here.

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.

SECTION 02 · Sequence
SEQUENCE·POST/login
Sequence diagram: /login, 11 messages between 5 actorsEXTBrowserCDN + WAFAuth APIUsers DBSession storeALT[hash matches and lock_until is past][no row, mismatch, or locked]OPT[TOTP enrolled]1POST /login (email, password)2forward with signed client IP (rate limit passed)3SELECT hash, mfa_secret, lock_until BY email4row (or none)5SET sid -> user_id, TTL 12 h6DEL pre-login sid7200 { mfa_required: true }8POST /login/totp (code)9200 + Set-Cookie sid; HttpOnly; Secure; SameSite=Lax10UPDATE failed_attempts + 111401 Invalid email or password
Legendcallresponseerrorthe answer the caller getsEXTexternal actorfragment (alt / opt / loop)active
Lockout: 10 failures in 15 minSession TTL: 12 h idle

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.

SECTION 03 · Threat model

Login — STRIDE

THREAT MODEL
Threat model: 5 nodes, 4 flows, 11 threatsPrivate networkInternetEdgeEXTBrowserCDN + WAFAuth APIDBSession store(Redis)DBUsers DB (Postgres)POST /login (email, password)forward + signed client IPSELECT hash by email; UPDATE failed_attemptsSET sid -> user_id, TTL 12 h
Legendprocessexternal entitydata storetrust boundarytls (lock)internal
IDSTRIDETargetThreatMitigationSeverityStatus
T1SBrowserCredential stuffing with leaked email + password listsEdge rate limit 10/min per IP and 5/h per email; breached-password check on loginhighmitigated
T2SBrowserPhishing page relays password and TOTP code in real timeNone today; passkeys planned for Q1highaccepted
T3SAuth APIBrute force of the 6-digit TOTP code5 code attempts per pending login, then the login is discardedhighmitigated
T4TCDN + WAFSpoofed X-Forwarded-For dodges the per-IP limitAuth API reads only the CDN-signed client IP header and drops the restmediummitigated
T5TSession store (Redis)Another service on the network rewrites the sid -> user_id mapRedis ACL user scoped to the Auth API; network policy allows only Authmediumopen
T6RAuth APIUser denies a login from a device they did not useLogin audit row (time, IP, UA); email on first login from a new devicemediummitigated
T7IAuth APIResponse time reveals whether the email existsRun Argon2id against a fixed dummy hash when no row is foundmediumopen
T8IUsers DB (Postgres)Hash column read through SQL injection or a leaked backupParameterised queries; Argon2id m=64 MiB t=3; backups encrypted at resthighmitigated
T9DCDN + WAFLogin flood exhausts Argon2id CPU on the Auth APIWAF challenge above 500 req/s on /login; hashing worker pool capped at 32highmitigated
T10ESession store (Redis)Session fixation: attacker plants a sid before the victim logs inNew sid on every successful login; the pre-login sid is deletedhighmitigated
T11EBrowserSession cookie stolen through XSSHttpOnly + Secure + SameSite=Lax cookie; CSP with no inline scriptshighmitigated

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.

SECTION 04 · Comparison

Login controls

ControlEnforced atSettingProven by
Per-IP and per-email rate limitCDN + WAF10/min per IP; 5/h per emailload test login-ratelimit.spec
Signed client IPAuth APITrust only cdn-client-ip header; drop X-Forwarded-Forunit test on header parsing
Argon2id hashingAuth APIm=64 MiB, t=3, p=1unit test: one hash takes 200-300 ms
Generic failure and constant-time pathAuth API401 Invalid email or password; dummy hash on unknown emailtiming test: p95 delta under 5 ms (not yet written)
Account lockoutAuth API + Users DB10 failures in 15 min sets lock_untilintegration test
TOTP attempt capAuth API5 codes per pending loginintegration test
Session rotationAuth API + Session storenew sid on login; old sid deletedintegration test
Cookie flagsAuth APIHttpOnly; Secure; SameSite=Lax; Path=/e2e header assert
Content Security PolicyCDN + WAFdefault-src 'self'; no inline scripte2e header assert
WAF challengeCDN + WAFabove 500 req/s on /loginstaging load test
New-device emailAuth APIon first login from an unseen IP + UA pairintegration 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.

SECTION 05 · Risk register

Residual risk

highPhishing relays password and TOTP in real timeIdentityaccepted
L: med · I: high

Mitigation: Ship passkeys as the default second factor in Q1.

highRedis reachable by other services on the private networkPlatformmitigating
L: low · I: high

Mitigation: Scope the Redis ACL to the Auth API and add the network policy.

mediumTiming side channel confirms which emails existIdentityopen
L: low · I: med

Mitigation: Dummy-hash path plus the p95 timing test.

mediumWAF challenge blocks real users behind one shared NATaccepted
L: med · I: low
View the Markdown
```meta
title: Login path — threat model
subtitle: Where the email + password + TOTP login can be attacked, and which control stops each attack.
tag: DRAFT
```

```callout
tone: note
title: Assumptions
body: "The login is email + password with optional TOTP. The browser reaches the Auth API through a CDN with a WAF. Sessions are opaque ids in Redis, sent as a cookie. Password hashes are Argon2id in Postgres. Password reset and OAuth sign-in are separate paths and are out of scope here."
```

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.

```sequence
id: login-seq
endpoint: { method: POST, path: /login, status: 200 }
actors:
  - { id: Browser, name: Browser, external: true }
  - { id: Edge, name: CDN + WAF }
  - { id: Auth, name: Auth API }
  - { id: Users, name: Users DB }
  - { id: Sessions, name: Session store }
messages:
  - "Browser -> Edge: POST /login (email, password)"
  - "Edge -> +Auth: forward with signed client IP (rate limit passed)"
  - "Auth -> Users: SELECT hash, mfa_secret, lock_until BY email"
  - "Users --> Auth: row (or none)"
  - "alt: hash matches and lock_until is past"
  - "Auth -> Sessions: SET sid -> user_id, TTL 12 h"
  - "Auth -> Sessions: DEL pre-login sid"
  - "opt: TOTP enrolled"
  - "Auth --> Browser: 200 { mfa_required: true }"
  - "Browser -> Auth: POST /login/totp (code)"
  - "end"
  - "Auth --> -Browser: 200 + Set-Cookie sid; HttpOnly; Secure; SameSite=Lax"
  - "else: no row, mismatch, or locked"
  - "Auth -> Users: UPDATE failed_attempts + 1"
  - "Auth -x-> -Browser: 401 Invalid email or password"
  - "end"
foot:
  - { label: Lockout, value: 10 failures in 15 min }
  - { label: Session TTL, value: 12 h idle }
```

## 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.

```threatmodel
id: login-stride
title: Login — STRIDE
boundaries:
  - { id: inet, col: 1, row: 1, cols: 1, rows: 2, label: Internet }
  - { id: edge, col: 2, row: 1, cols: 1, rows: 2, label: Edge }
  - { id: core, col: 3, row: 1, cols: 2, rows: 2, label: Private network }
nodes:
  - { id: browser, col: 1, row: 1, name: Browser, kind: external }
  - { id: edge, col: 2, row: 1, name: CDN + WAF }
  - { id: auth, col: 3, row: 1, name: Auth API }
  - { id: sessions, col: 4, row: 1, name: Session store (Redis), kind: store }
  - { id: users, col: 4, row: 2, name: Users DB (Postgres), kind: store }
edges:
  - { from: browser, to: edge, label: "POST /login (email, password)", channel: tls }
  - { from: edge, to: auth, label: "forward + signed client IP", channel: tls }
  - { from: auth, to: users, label: "SELECT hash by email; UPDATE failed_attempts", channel: internal }
  - { from: auth, to: sessions, label: "SET sid -> user_id, TTL 12 h", channel: internal }
threats:
  - { id: T1, target: browser, category: S, threat: "Credential stuffing with leaked email + password lists", mitigation: "Edge rate limit 10/min per IP and 5/h per email; breached-password check on login", severity: high, status: mitigated }
  - { id: T2, target: browser, category: S, threat: "Phishing page relays password and TOTP code in real time", mitigation: "None today; passkeys planned for Q1", severity: high, status: accepted }
  - { id: T3, target: auth, category: S, threat: "Brute force of the 6-digit TOTP code", mitigation: "5 code attempts per pending login, then the login is discarded", severity: high, status: mitigated }
  - { id: T4, target: edge, category: T, threat: "Spoofed X-Forwarded-For dodges the per-IP limit", mitigation: "Auth API reads only the CDN-signed client IP header and drops the rest", severity: medium, status: mitigated }
  - { id: T5, target: sessions, category: T, threat: "Another service on the network rewrites the sid -> user_id map", mitigation: "Redis ACL user scoped to the Auth API; network policy allows only Auth", severity: medium, status: open }
  - { id: T6, target: auth, category: R, threat: "User denies a login from a device they did not use", mitigation: "Login audit row (time, IP, UA); email on first login from a new device", severity: medium, status: mitigated }
  - { id: T7, target: auth, category: I, threat: "Response time reveals whether the email exists", mitigation: "Run Argon2id against a fixed dummy hash when no row is found", severity: medium, status: open }
  - { id: T8, target: users, category: I, threat: "Hash column read through SQL injection or a leaked backup", mitigation: "Parameterised queries; Argon2id m=64 MiB t=3; backups encrypted at rest", severity: high, status: mitigated }
  - { id: T9, target: edge, category: D, threat: "Login flood exhausts Argon2id CPU on the Auth API", mitigation: "WAF challenge above 500 req/s on /login; hashing worker pool capped at 32", severity: high, status: mitigated }
  - { id: T10, target: sessions, category: E, threat: "Session fixation: attacker plants a sid before the victim logs in", mitigation: "New sid on every successful login; the pre-login sid is deleted", severity: high, status: mitigated }
  - { id: T11, target: browser, category: E, threat: "Session cookie stolen through XSS", mitigation: "HttpOnly + Secure + SameSite=Lax cookie; CSP with no inline scripts", severity: high, status: 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.

```table
title: Login controls
columns: [Control, Enforced at, Setting, Proven by]
rows:
  - [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"]
note: "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.

```risk
title: Residual risk
items:
  - { risk: Phishing relays password and TOTP in real time, likelihood: med, impact: high, mitigation: Ship passkeys as the default second factor in Q1., owner: Identity, status: accepted }
  - { risk: Redis reachable by other services on the private network, likelihood: low, impact: high, mitigation: Scope the Redis ACL to the Auth API and add the network policy., owner: Platform, status: mitigating }
  - { risk: Timing side channel confirms which emails exist, likelihood: low, impact: med, mitigation: Dummy-hash path plus the p95 timing test., owner: Identity, status: open }
  - { risk: WAF challenge blocks real users behind one shared NAT, likelihood: med, impact: low, status: accepted }
```