Codetail

Article 5 of 12

Authentication and Session Management Failures

Login is the highest-value target in the entire app.

24 min read

Credential stuffing: the bug isn't in your code

Somewhere, a list of a few hundred million real email and password pairs from some other company's breach is being tried against your login form right now, by automation, not a person, on the bet that some fraction of your users reused a password. Nothing in your code caused this list to exist. It still has to be your code that stops it from working.

A login endpoint with no rate limiting at all

PythonVulnerable
1@app.route("/login", methods=["POST"])
2def login():
3 user = db.query(User).filter_by(email=request.form["email"]).first()
4 if user and check_password_hash(user.password_hash, request.form["password"]):
5 session["user_id"] = user.id
6 return redirect("/dashboard")
7 return "Invalid email or password", 401

This is worth reading twice, because it's already doing one thing right: the error message is identical whether the email doesn't exist or the password is wrong, so it doesn't hand an attacker a way to enumerate valid accounts one guess at a time. What it doesn't do is limit how many guesses anyone gets, at any speed, from any number of source IPs.

That's the distinction from plain brute force. Brute force hammers one account with many passwords, and a simple per-account lockout catches it. Credential stuffing spreads one password per account across your entire user base, often from thousands of rotating IPs through a botnet, so no single account and no single IP looks unusual. A lockout policy built for brute force doesn't even notice.

Fixed: rate limit by account and by source together

PythonFixed
1limiter = Limiter(
2 app,
3 key_func=lambda: request.form.get("email", "") or get_remote_address(),
4)
5
6@app.route("/login", methods=["POST"])
7@limiter.limit("5 per minute")
8def login():
9 user = db.query(User).filter_by(email=request.form["email"]).first()
10 if user and check_password_hash(user.password_hash, request.form["password"]):
11 session["user_id"] = user.id
12 return redirect("/dashboard")
13 return "Invalid email or password", 401

Rate limiting slows a distributed attack down, it doesn't stop it, a patient enough attacker with enough IPs works around any threshold you pick. The defense that actually closes this off is a second factor: a stuffed credential that happens to be correct still can't get past MFA. Failing that, checking new passwords against a known-breach list (the Have I Been Pwned API is the common choice) and forcing a reset when there's a match closes the specific hole credential stuffing depends on, before anyone even tries to log in with it.

Session fixation: the ID doesn't change when you think it does

Most frameworks hand every visitor a session ID before they've logged in at all, useful for a shopping cart or a CSRF token that needs to exist pre-authentication. The bug shows up when that same ID just gets promoted to "authenticated" after login, with nothing about the ID itself ever changing.

A login handler that trusts whatever session ID the request already had

PythonVulnerable
1@app.route("/login", methods=["POST"])
2def login():
3 user = authenticate(request.form["email"], request.form["password"])
4 if user:
5 session["user_id"] = user.id # same session ID as before login
6 return redirect("/dashboard")
7 return "Invalid credentials", 401

Here's the attack. The attacker visits the site once, unauthenticated, and notes their own session ID from the cookie the server just handed them, say abc123. They get the victim to open a link that plants that exact same ID in the victim's browser, some apps accept a session ID from a query string or a shared parent domain cookie, which is its own bug but a common one. The victim logs in normally, with their own real credentials, in their own browser. The session referenced by abc123 is now authenticated as the victim. The attacker already knows abc123, so their own browser is too.

Nobody stole a password here, and nobody read a cookie they weren't supposed to. The attacker planted an ID before authentication happened, and the app never bothered to assign a new one once it did.

Fixed: a new session ID on every privilege change

PythonFixed
1@app.route("/login", methods=["POST"])
2def login():
3 user = authenticate(request.form["email"], request.form["password"])
4 if user:
5 session.regenerate_id() # old ID, whatever it referenced, is now dead
6 session["user_id"] = user.id
7 return redirect("/dashboard")
8 return "Invalid credentials", 401

Whatever the attacker planted stops mattering the moment login succeeds, because the ID that becomes authenticated is a fresh one they never saw. Most frameworks have this built in under some name (Django calls it cycle_key(), plenty of session libraries call it regenerate()). The same call belongs on logout and on password change too, any point where the session's privilege level actually shifts.

Password reset: authentication's back door

The login form gets the scrutiny. The "forgot your password" flow next to it, which grants full account access to whoever proves control of an email address, usually gets less. Two specific bugs show up here often enough to name them.

Reset tokens that are only pretending to be random

A token that looks random because it's hashed

PythonVulnerable
1def generate_reset_token(user):
2 raw = f"{user.id}-{int(time.time())}"
3 return hashlib.md5(raw.encode()).hexdigest()

This produces a string that looks exactly like every other hash, and it's completely determined by two things an attacker can pin down: the user's ID, often sequential or leaked through an unrelated IDOR, and the timestamp of the request, narrowable to a small window from something as simple as when the reset email arrived. Brute-forcing every second in a five-minute window offline takes nothing on modern hardware. The token was never a secret, it was a hash of two numbers the attacker already had or could guess.

Fixed: a token with nothing to derive

PythonFixed
1def generate_reset_token(user):
2 token = secrets.token_urlsafe(32)
3 store_reset_token(user.id, token, expires_in_minutes=15, single_use=True)
4 return token

secrets.token_urlsafe isn't derived from anything guessable, it's generated from the OS's cryptographic random source. Storing it server-side means it can expire, and get invalidated the moment it's used once, so a token that leaked but was never used stops being useful after fifteen minutes instead of forever.

Trusting the Host header to build the reset link

The email your app sends

PythonVulnerable
1@app.route("/forgot-password", methods=["POST"])
2def forgot_password():
3 user = get_user_by_email(request.form["email"])
4 token = generate_reset_token(user)
5 reset_link = f"https://{request.headers['Host']}/reset?token={token}"
6 send_email(user.email, f"Reset your password: {reset_link}")

A browser sets the Host header to match the address bar, but nothing requires that. A raw HTTP request can set Host to anything, and if your app or the infrastructure in front of it doesn't check it against an allowlist, an attacker submits the victim's real email address to /forgot-password with Host: evil.example. The token is real, the email comes from your legitimate sending address, and the link inside it points to a page the attacker controls. The victim reads a completely genuine email and clicks a completely poisoned link.

Fixed: the host comes from your config, never from the request

PythonFixed
1ALLOWED_HOST = "app.example" # from config, not from the request
2
3@app.route("/forgot-password", methods=["POST"])
4def forgot_password():
5 user = get_user_by_email(request.form["email"])
6 token = generate_reset_token(user)
7 reset_link = f"https://{ALLOWED_HOST}/reset?token={token}"
8 send_email(user.email, f"Reset your password: {reset_link}")

Anywhere a request header feeds into a link, a redirect, or anything else you're asking a user to trust, treat that header the way you'd treat a form field: it came from the client, and the client can put whatever it wants there.

Token expiry and rotation

Authentication doesn't end at login. A session or token that lives forever is a standing invitation, and JWTs make it easy to build one without noticing, because nothing forces you to add an expiry.

A token with no expiry claim

PythonVulnerable
1def issue_token(user):
2 return jwt.encode({"user_id": user.id}, SECRET_KEY, algorithm="HS256")

A JWT is verified by its signature alone. Without an exp claim, this token is valid for as long as the signing key doesn't change, which in practice means forever. If it leaks, a log line that captured a header it shouldn't have, a compromised laptop, a request logged by a debugging proxy, there's no way to invalidate that one token specifically. The only lever is rotating the signing key, which logs out every user, not just the one whose token leaked.

Fixed: a short-lived access token, backed by a revocable refresh token

PythonFixed
1def issue_access_token(user):
2 payload = {
3 "user_id": user.id,
4 "exp": datetime.utcnow() + timedelta(minutes=15),
5 }
6 return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
7
8def issue_refresh_token(user):
9 token = secrets.token_urlsafe(32)
10 store_refresh_token(user.id, token, expires_in_days=30)
11 return token

A leaked access token now has a fifteen-minute shelf life instead of an unlimited one. The refresh token does the work of avoiding a re-login every fifteen minutes, and because it's stored server-side rather than just verified by a signature, it actually can be revoked on its own, without touching anyone else's session.

Rotation turns theft into something you can detect

A refresh token that gets reused indefinitely is still a long-lived secret, just one layer removed from the access token. Rotate it on every use, issue a new one and kill the old one in the same request, and a stolen refresh token starts behaving strangely the moment both the attacker and the legitimate client try to use it: whichever one goes second is presenting a token that's already been retired.

Reuse of a retired refresh token is treated as a compromise signal

PythonFixed
1def use_refresh_token(token):
2 record = get_refresh_token(token)
3 if record is None or record.revoked:
4 # this exact token was already rotated out once; someone else has a copy
5 revoke_token_family(record.family_id)
6 abort(401)
7 revoke_token(token)
8 return issue_refresh_token(record.user, family_id=record.family_id)

That reuse check is the actual point of rotating in the first place. It doesn't just shrink the window a stolen token is useful for, it turns theft into an event your system can notice and react to, by killing every token descended from the same login instead of waiting for someone to report suspicious activity on their account.