Skip to content

LearnWeb

Authentication and session checks

Login, logout, password reset, tokens and rate limits: the authentication and session checks we run on every web app, and the server-side fix for each.

Area
Web
Stage
Access
Reviewed
On this page
  1. What to check first
  2. Username enumeration
  3. Session fixation
  4. Logout and session lifetime
  5. Signed tokens
  6. Password reset
  7. Attempt limits on codes
  8. What the defender sees
  9. Tools
  10. Related research

Authentication bugs rarely live in the password check itself. They live in the paths around it: the reset flow, the second login method nobody tested, the session that survives logout, the token the server never verifies. We test every way into an account, because the weakest one sets the bar for all of them.

Run these checks only against applications named in the scope letter, and use your own test accounts for everything on this page. Nothing here needs a real user's account.

What to check first

  • Every way in: password, SSO, magic link, API key, the mobile app's endpoints and password reset.
  • Whether the session identifier changes at login.
  • Whether logout ends the session on the server or only clears the cookie.
  • Attempt limits on login, on reset requests and on one-time code entry.
  • Whether login, registration and reset responses reveal which accounts exist.

Username enumeration

for u in tester1@example.com no-such-user-7f3a@example.com; do
  for i in $(seq 10); do
    curl -s -o /dev/null -w '%{http_code} %{size_download} %{time_total}\n' \
      -X POST https://app.example.com/api/login -H 'Content-Type: application/json' \
      -d "{\"email\":\"$u\",\"password\":\"Wrong-Password-$i\"}"
  done | sort -k3 -n | sed -n '5,6p' | sed "s|^|$u |"
done

We compare status, response size and timing for a real test account and a name that cannot exist. One request per name proves nothing about timing, so we send ten each and compare the middle values. Before that we confirm ten failures will not lock the test account. A consistent gap, where the server only hashes passwords for real accounts, is enough. Registration and reset forms leak more often than login does.

Fix. Return the same status and message for both cases, and run the password hash against a dummy value when the account does not exist so timing matches. Reset forms say "if an account exists, we have sent a link" every time.

Session fixation

We record the session cookie before login and after it. If the value is the same, an attacker who plants a session identifier in a victim's browser before login owns the session after it.

Fix. Issue a new session identifier at login, at logout, and whenever the user's privileges change.

Logout and session lifetime

# captured while logged in, replayed after logging out
curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'Cookie: session=<value captured before logout>' https://app.example.com/api/me

A 200 after logout means the server never invalidated the session; logout only told the browser to forget it. We also note how long an idle session lasts and whether there is any absolute limit.

Fix. Keep session state on the server so logout can delete it. If you use signed tokens, keep access tokens short-lived, rotate refresh tokens on every use, and keep a revocation list that logout and password change write to. Set both an idle timeout and an absolute one.

Signed tokens

python3 - "$JWT" <<'PY'
import base64, json, sys
for part in sys.argv[1].split('.')[:2]:
    part += '=' * (-len(part) % 4)
    print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2))
PY

We read the header and claims, then check that the server rejects a token with the signature stripped, a token that declares alg as none, a token with an expired exp, and a token signed for a different environment. We also read the claims for anything that should not be there, such as roles the server trusts without checking, or personal data.

Fix. Pin the accepted algorithm on the server and ignore the token's own header for that decision. Verify every token on every request, keep expiry short, and treat claims as data to check against your own records rather than as permission.

Password reset

curl -s -X POST https://app.example.com/api/password/reset \
  -H 'Content-Type: application/json' -H 'X-Forwarded-Host: tester-controlled.example' \
  -d '{"email":"tester1@example.com"}'

We then open the reset email in the test mailbox. If the link points at the host we supplied, an attacker can request a reset for any user and collect the token when the victim clicks. We also check that the token is long and random, works once, expires within an hour, and is tied to one account.

Fix. Build every link in outbound email from a configured base URL, never from request headers. Store reset tokens hashed, expire them quickly, and invalidate them on use and on any password change.

Attempt limits on codes

We send a small number of wrong one-time codes to our own test account and watch for a block. A six digit code with no attempt limit falls to automation; we prove the missing limit with a few dozen attempts, not a million.

Fix. Limit attempts per account and per code, invalidate the code after a handful of failures, and alert on bursts. The same applies to login: per-account and per-source limits, with lockout that slows an attacker without letting them lock out everyone. The password spray page covers the identity provider side.

What the defender sees

Failed logins and reset requests in application logs, if the application logs them. Many do not log the reset flow at all; that absence goes in the report too.