Testing Login Abuse: Enumeration, Rate Limits, and a Lockout That Holds
A credential-stuffing script does not need a zero-day. It needs one endpoint, a list of email/password pairs, and a server that answers every attempt identically forever. Your job on staging is to find out which of three things the login form does under pressure: slow down, lock out, or leak the account list.
Start with enumeration. Two responses that differ in wording hand an attacker a working directory of valid addresses:
{"error":"no account exists for kim@example.com"} <- address rejected
{"error":"incorrect password"} <- address exists
Both must collapse into one generic body — invalid email or password — with the same status code. Wording is the easy half. The hard half is timing: servers that hash only after finding a user (bcrypt at 100ms per hash) answer known addresses measurably slower, so an attacker scripts both and clusters the latency:
for i in $(seq 1 10); do
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" \
-X POST https://staging.example.com/login \
-d "email=definitely-not-a-user@example.com&password=wrong-$i"
done
Run the same loop against an account you created. A consistent gap of 60-100ms is enumeration by timing even when the messages match — the fix is hashing a dummy password when the lookup misses, and it belongs in your bug report. The same leak sits one door over: a signup form that answers email already registered is enumeration wearing a friendlier face, so probe registration with those same two addresses and confirm the flow mails a verification link instead of confirming existence inline.
Next, pressure the endpoint and compare what happens with what should happen:
| Probe | Healthy server | Broken server |
|---|---|---|
| unknown email | generic failure, identical body and timing | user not found, or a fast 200ms reply |
| 6 rapid failures | 429 with Retry-After: 30, or a lock message |
six identical 401s in a row |
| valid password during the lock window | still rejected until the window ends | accepted on the next attempt |
| 100 accounts tried from one IP | IP throttle or CAPTCHA | unlimited throughput |
Check both axes: per-account lockout stops one account being ground down, per-IP throttling stops one machine trying many accounts. Lockout alone has a known flaw — an attacker who can trigger it denies service to the real owner — so the design you want is exponential backoff plus a CAPTCHA, not a permanent hard lock.
Then audit the policy itself. NIST SP 800-63B is the reference most teams claim to follow: minimum 8 characters, maximum 64, no forced composition rules, no periodic rotation, and every candidate screened against breached-password lists (the Have I Been Pwned range API takes the first 5 hex characters of a SHA-1 and returns the matching suffixes). Verify the password travels only in the POST body over TLS — never in the query string, where access logs and Referer headers will happily record it — and never into client-side analytics. The reset flow gets the same scrutiny: single-use token, 15-30 minute expiry, and a response that does not reveal whether the address exists.
Interviewers ask "how would you test brute-force protection?" expecting a sequence: send failures until the server objects, record the status code and Retry-After, prove the lock holds with a valid password, then probe the sibling accounts and the IP axis. The login surface is built on tokens and sessions, which is where OWASP Security Testing Basics handed the problem off — and where chapter 2 of this topic goes next.
Reading a JWT in DevTools: Expiry, Storage, and Rotation After Privilege Changes
Paste a session token into jwt.io or decode it on the command line and you get three base64url segments: a header, a payload, and a signature that proves nobody edited the first two. The payload is readable by design, so treat every claim in it as a promise the server may have to keep long after the user's circumstances changed.
header {"alg":"HS256","typ":"JWT"}
payload {"sub":"4129","role":"admin","iat":1768191900,"exp":1768192800}
Four checks come out of that decode:
expminusiat— this pair spans 900 seconds, which is a sane access-token lifetime. A 30-dayexpmeans every stolen token is a 30-day key, and the number you see is the number to quote in your report.alg— the server must refusealg:noneoutright and must not let a client choose the algorithm (the classic RS256-to-HS256 confusion verifies the token with the public key as if it were a shared secret).role— a role baked into the token is a role that survives the database. When an admin is demoted, the token still saysadminuntil it expires.jti— the presence of a token id tells you revocation is possible; without it, "logout" cannot reach a stateless token at all.
Testing alg is hands-on: take your own valid token, rewrite the header to {"alg":"none","typ":"JWT"}, keep the payload, delete the signature, and replay it. A vulnerable server answers with data; a hardened one returns 401 without ever reading your claims.
Where the token lives decides how much an XSS is worth:
| Storage | Script can read it | CSRF exposed | Verdict |
|---|---|---|---|
localStorage |
yes — any injected script dumps it | no | wrong place for a session token |
httpOnly cookie |
no (document.cookie comes back empty) |
yes, so add SameSite |
the default you should expect |
| memory only | no | no | strongest for SPAs, lost on reload |
Open DevTools and run document.cookie: if the session id appears in the output, the HttpOnly flag is missing. Inspect the login response for the full shape — Set-Cookie: sid=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900 — where Secure keeps it off plain HTTP and SameSite blocks cross-site sends.
Transport is the other half of storage. A bearer token in the Authorization header stays out of access logs and proxy history; the same token passed as ?access_token= lands in URLs, CDN logs, and browser history. If the API accepts both channels, grep the access log for a token you just sent and see which one the server actually recorded.
Rotation is the check most suites skip. Call the refresh endpoint twice with the same refresh token: the second call must return 401 and invalidate the whole token family, because a replayed refresh token means the token was stolen. Privilege changes deserve the same treatment: demote a test admin to viewer in the database, then call an admin endpoint with the token issued before the change.
curl -i https://staging.example.com/api/admin/users \
-H "Authorization: Bearer $TOKEN"
Either answer is defensible — the server re-reads the role from the database on every request, or the token carries the role with a short exp and a revocation list — but "the token stays valid until it expires" means a demoted employee keeps admin for the whole lifetime you chose. State that window out loud in the review; it is the design decision under test. Session cookies behave differently from bearer tokens, and the two-device logout experiment in the next chapter is where those differences stop being theoretical.
Does Logout Actually Log You Out? Proving Invalidation on Every Device
Logout that only deletes a cookie client-side is a magician's flourish: the browser forgets, the server does not. Run the two-browser experiment on staging and you will know which one you have in under two minutes.
- Sign in with the same account in Chrome and in Firefox.
- Copy both session cookies (or both bearer tokens) into a text file.
- Log out in Chrome.
- Replay Chrome's captured cookie with
curl -i https://staging.example.com/api/me -H "Cookie: sid=$OLD". - Use
sign out of all devicesif the product offers one, then refresh the Firefox tab.
| Check | Pass condition |
|---|---|
| replay after logout | 401 Unauthorized, not a 200 with profile data |
| second device after "sign out everywhere" | rejected immediately |
| logout response | Set-Cookie: sid=; Max-Age=0 plus server-side deletion of the session record |
| session id before vs after login | different values — no session fixation |
| after password change | every other session revoked; the device that changed it may keep working |
That fourth row deserves emphasis. If the pre-login session id survives authentication, an attacker who plants a known cookie (a link with ?sid=attacker-controlled) can wait for the victim to log in and then use the now-authenticated id. Session id rotation at login and at password change closes it, and it is a two-line regression test once you think to write it.
A failing replay also tells you where to look: if the session record still exists in the store after logout, the handler never deleted it; if the store is clean but the API still answers, then the cookie path and the token path are two separate mechanisms and only the browser half was cleared. One lookup of the session key against staging settles it.
Timing checks round out the flow: authenticated responses should carry cache-control: no-store so the back button does not repaint private data, idle timeouts (30 minutes is typical) and absolute timeouts (8-12 hours) must both be enforced server-side, and an expired session should redirect to login instead of returning half-rendered fragments. Test them by waiting rather than by trusting the config file — a timeout set in middleware that never runs on the API path is a timeout that does not exist.
The check belongs in the suite, not in a wiki:
def test_logout_invalidates_session(client, authed_client):
sid = authed_client.cookie_jar.get("sid")
authed_client.post("/logout")
replay = client.get("/api/me", headers={"Cookie": "sid=" + sid})
assert replay.status_code == 401, "session survived logout"
When you file the result, structure beats adjectives. A finding a developer can act on reads like this:
Title: Logout does not invalidate the session server-side
Severity: High — CWE-613 Insufficient Session Expiration
Environment: staging.example.com, build 2026.03.11
Steps: log in on Chrome; copy the sid; log out; replay the sid with curl
Expected: 401 Unauthorized
Actual: 200 with full profile JSON
Impact: any shared or kiosk browser exposes the account after logout
Remediation: delete the session server-side on logout, revoke refresh
tokens, and add the replay request as a regression test
Pick severity from impact, not irritation: account takeover paths and surviving sessions are High or Critical, user enumeration at scale is High, a missing SameSite attribute is Medium, a version header is Low. Test only on accounts you own, report privately with a agreed window, and keep exploit steps reproducible — that discipline is what separated the manual probing you did in OWASP Security Testing Basics from guessing. With auth closed out, the roadmap turns to how this code is written at all in Design Patterns in Test Code.
Quiz
1. Login returns {"error":"no account exists for that address"} for unknown emails but {"error":"incorrect password"} for real ones. Which finding is this, and what is the primary fix?
2. A JWT payload decodes to {"role":"admin","iat":1768191900,"exp":1768278300}. The user was demoted to viewer one hour after login. What does a correct implementation do when that token hits an admin endpoint?
3. After clicking logout, you replay the captured session cookie with curl and the API answers 200 with profile data. What is the root cause, and the correct remediation?
4. Six rapid failed logins return six 401s, then a valid password on the seventh attempt succeeds immediately. Which statement best describes the gap?
Flashcards
Question
What are the two independent signals of user enumeration on a login form, and how do you test each?
Click to reveal answer
Answer
Response wording (a distinct 'user not found' versus 'wrong password' body) and timing (the server hashes only when the account exists, so known addresses answer 60-100ms slower). Test wording by comparing the two error bodies, and timing with a loop of curl -w "%{time_total}" requests against an unknown address and one of your own, looking for a consistent gap.
Question
Which four JWT claims do you inspect first, and what does each one tell you?
Click to reveal answer
Answer
exp minus iat (token lifetime — quote it as the stolen-token window), alg (must reject none and refuse client-controlled algorithm choice), role (a claim that survives database changes until expiry), and jti (its presence means server-side revocation is possible). Signature validity only proves the claims were not edited, not that they are still true.
Question
Where should a session token be stored in a browser, and what does running document.cookie tell you?
Click to reveal answer
Answer
An httpOnly, Secure, SameSite cookie: httpOnly keeps document.cookie from exposing the session to injected script, Secure blocks plain-HTTP transmission, SameSite blunts CSRF. If document.cookie prints the session id, HttpOnly is missing; if localStorage holds it, any XSS reads the token directly.
Question
What is the two-browser test that proves logout invalidates sessions, and what must it show?
Click to reveal answer
Answer
Log the same account in on two browsers, copy both cookies, log out in the first, then replay its cookie with curl and refresh the second tab. Passing means the replay returns 401 (session deleted server-side, not just the cookie), and a 'sign out of all devices' action kills the second session immediately; a 200 with profile data fails it.
Question
How do you decide severity when writing up an auth or session finding?
Click to reveal answer
Answer
By impact, not annoyance: account takeover and sessions that survive logout are Critical or High, enumeration at scale is High, a missing SameSite or cookie flag is Medium, a version-revealing header is Low. Every finding carries environment and build, reproducible steps, expected versus actual, business impact, and a concrete remediation with a regression-test suggestion.
Revision Notes
Key Takeaways
- 1. Enumeration lives in two places — message wording and hashing latency — so test both with a timed curl loop before declaring the login generic
- 2. Rate limiting has two axes: per-account lockout and per-IP throttling; a hard lock without backoff trades brute force for denial of service against the owner
- 3. A JWT is a claim from the past: exp defines the stolen-token window and role claims survive database changes unless the server re-checks or revokes
- 4. Session tokens belong in httpOnly Secure SameSite cookies, and document.cookie is the fastest field test of whether HttpOnly is set
- 5. Logout is only real when the server deletes the session — the two-browser replay test proves it, and a 200 on the replayed cookie is the bug
Interview Tips
- • "How do you test brute-force protection?" — drive failures until the server objects, record 429 and Retry-After, prove the lock holds with a valid password, then test the many-accounts-from-one-IP axis; naming the enumeration-timing check shows depth
- • "Where should a web app store a session token?" — httpOnly Secure SameSite cookie, and say why each flag exists: XSS cannot read it, it never rides plain HTTP, and SameSite blunts CSRF; contrast with localStorage explicitly
- • "An admin was demoted but their token still works. Is that a vulnerability?" — it is a design decision with a numeric cost: the remaining exp window; defend either per-request role checks or revocation with a short TTL
- • "Walk me through proving logout is broken." — two browsers, capture both cookies, log out one, replay with curl, expect 401; add session id rotation and password-change revocation as the follow-up checks developers rarely have tests for
Cheat Sheet
Auth and Session Security Checks
Login abuse probes:
| Probe | Pass | Fail |
|---|---|---|
| unknown vs known address | identical body, status, timing | distinct message or 60-100ms gap |
| 6 rapid failures | 429 + Retry-After: 30 or lock |
six straight 401s |
| valid password during lock | still rejected | accepted at once |
| many accounts, one IP | IP throttle / CAPTCHA | unbounded attempts |
Timing probe:
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" \
-X POST https://staging.example.com/login \
-d "email=known@example.com&password=wrong"
Policy baseline (NIST SP 800-63B): 8 min / 64 max characters, no composition rules, no forced rotation, screen candidates against breached lists (HIBP range API on the SHA-1 prefix). Password only in the POST body over TLS — never query strings, logs, or analytics.
Token checklist: decode header.payload.signature; verify exp - iat (access tokens measured in minutes), alg rejects none and client control, jti exists for revocation, role is re-checked or revoked on privilege change. Storage: HttpOnly; Secure; SameSite cookie — confirm with document.cookie returning nothing sensitive. Refresh reuse must 401 and kill the token family.
Session checklist: replay after logout returns 401; Set-Cookie: sid=; Max-Age=0 plus server-side delete; session id rotates at login (no fixation); password change revokes other sessions; cache-control: no-store on private responses; idle and absolute timeouts enforced on the API path, not just in middleware.
Finding shape: Title / Severity (CWE) / Environment + build / Steps / Expected / Actual / Impact / Remediation + regression test.