Authentication testing is easiest when you stop thinking about a single login form.

Think about a state machine:

identify -> password verify -> MFA -> session -> remember-me -> reset/change password

The 14 PortSwigger authentication labs break one edge of that machine at a time.

The map

Class Example
username oracle different text, subtle text, timing, account lock
brute-force logic flaw successful login resets failure counter
MFA state flaw password-authenticated user can browse to account page
MFA binding flaw client chooses verify=carlos
remember-me weakness base64(username:md5(password))
reset logic flaw token not checked on final password reset
reset poisoning X-Forwarded-Host controls reset link host
change-password oracle error message reveals correct current password
bulk credentials JSON password array processed as one request

Username oracles are not always obvious

The easy version returns different text:

Invalid username
Incorrect password

The subtle version differs only by punctuation or whitespace. The timing version takes longer for valid usernames when a long password forces extra work. Account lock creates another oracle because only real accounts reach the lock state.

The defensive requirement is uniformity: text, status, length, and timing should not disclose which identity step succeeded.

Rate limits need the right key

One brute-force lab can be bypassed by interleaving a known valid login:

carlos:candidate
wiener:peter
carlos:next-candidate
wiener:peter

Another accepts a JSON array:

{
  "username": "carlos",
  "password": ["123456", "password", "qwerty"]
}

The protection counts requests, but the application tries multiple credentials inside one request.

Rate limits need to understand usernames, source behavior, device/session state, and request semantics.

MFA must bind the challenge

The simple bypass is a missing state check: after password authentication, the user can directly browse to /my-account.

The broken-logic MFA lab lets the client decide which user is being verified:

verify=carlos

The brute-force MFA lab requires rebuilding the login state before each code attempt:

GET /login
POST /login
GET /login2
POST /login2 mfa-code=0000..9999

The invariant is that MFA code, user, session, challenge, purpose, and expiry must be tied together server-side.

The broken reset lab ignores the reset token at the final step and trusts a hidden username field. The reset-poisoning lab builds reset links from X-Forwarded-Host, letting an attacker receive the victim’s token in an access log.

Remember-me cookies fail when they are deterministic:

base64(username + ":" + md5(password))

That format can be generated for candidate passwords or cracked offline after cookie theft. A safe persistent login token should be random, server-side verifiable, revocable, and unrelated to the password hash.

Defender notes

Hardening:

  • unify login failure text, status, length, and timing;
  • rate-limit by username, IP, device/session, and global risk signals;
  • bind MFA challenges to user, session, purpose, and expiry;
  • require MFA-complete state before protected resources;
  • bind reset tokens to user and action, then validate them on the final action;
  • generate reset links from fixed server configuration;
  • use random server-side remember-me tokens;
  • unify password-change error messages;
  • reject type changes such as password string to array.

Detection:

  • dictionary-shaped username or password attempts;
  • rotating X-Forwarded-For on login requests;
  • many MFA code submissions in sequence;
  • password reset requests with abnormal Host or X-Forwarded-Host;
  • remember-me cookies that decode to username:hash;
  • successful known-account logins interleaved with victim attempts;
  • JSON password fields submitted as arrays.

The useful habit is state accounting. For every authentication step, ask what fact was proven, where it is stored, which user it is bound to, and whether the next step actually checks it.