Production authentication gotchas

Seven real-world authentication attacks and defensive patterns: session fixation (don't trust client-provided IDs), CSRF (state verification and SameSite cookies), timing attacks (constant-time comparison), account enumeration (don't leak whether a user exists), weak password reset links, token leakage in logs, and race conditions in concurrent auth checks.

Advanced

6 min read

Gotcha 1: Session fixation attacks

An attacker tricks a user into using a known session ID, then hijacks it.

Attack:

1. Attacker obtains a session ID (or crafts one): session_id = "abc123"
2. Attacker tricks Alice into logging in with that ID
3. Alice logs in, and the server associates session abc123 with alice's account
4. Attacker uses session abc123 to act as Alice

Defense: Always generate a new session ID after login, don't trust the client's ID:

# WRONG — reuses the session ID the client provides
@app.post('/login')
def login():
    session_id = request.cookies.get('session_id')  # attacker-provided
    session_id = session_id or generate_new_id()
    authenticate_user(email, password)
    sessions[session_id] = {'user_id': user.id}
    return {'session_id': session_id}
 
# CORRECT — always generates a new session after login
@app.post('/login')
def login():
    authenticate_user(email, password)
    new_session_id = generate_new_id()  # ignore client's session
    sessions[new_session_id] = {'user_id': user.id}
    response.set_cookie('session_id', new_session_id, httponly=True, secure=True)
    return 200

Gotcha 2: CSRF (Cross-Site Request Forgery)

An attacker tricks a user into making unwanted requests to a trusted site.

Attack:

1. Alice is logged into her bank (bank.com)
2. Alice visits attacker.com in another tab
3. attacker.com contains: <img src="https://bank.com/transfer?amount=1000&to=attacker" />
4. Browser automatically sends the request with Alice's bank cookies
5. Alice's money transfers without her knowledge

Defense 1: SameSite cookie flag

response.set_cookie('session_id', token, samesite='Strict')

SameSite=Strict prevents the cookie from being sent on cross-site requests. attacker.com can't send a request to bank.com with the session cookie.

Defense 2: CSRF tokens

For state-changing operations (POST, DELETE), require a token:

@app.post('/transfer')
def transfer():
    provided_token = request.form.get('csrf_token')
    session_token = request.session.get('csrf_token')
    
    if provided_token != session_token:
        return 403  # CSRF attack
    
    # Process transfer
    perform_transfer(amount, recipient)
    return 200

The server generates a CSRF token, stores it in the session, and requires the client to send it back. The attacker can't get the token (same-origin policy blocks reading it), so they can't forge a valid request.

Gotcha 3: Timing attacks on password verification

An attacker measures how long a password check takes to guess characters.

Attack:

# WRONG — early exit on first mismatch
def check_password(provided, stored):
    for i in range(len(provided)):
        if provided[i] != stored[i]:
            return False  # exits early if char doesn't match
    return True
 
# Timing:
# "password1": comparison stops after 1 char ≈ 1μs
# "passwor" (correct prefix): comparison goes to char 7 ≈ 7μs
# Attacker uses timing differences to guess the password character by character

Defense: constant-time comparison

import hmac
 
# CORRECT — takes the same time regardless of where mismatch occurs
def check_password(provided, stored):
    return hmac.compare_digest(provided, stored)

hmac.compare_digest() compares every byte, so the time is always the same.

Gotcha 4: Account enumeration attacks

An attacker discovers which accounts exist by observing different responses.

Attack:

POST /password-reset
Email: alice@example.com

Response:
- If account exists: 200 "Password reset email sent"
- If account doesn't exist: 400 "Account not found"

Attacker tries 1M emails and sees which ones get 200s.

Defense: consistent response

Always return the same response, even if the account doesn't exist:

@app.post('/password-reset')
def password_reset():
    email = request.json.get('email')
    
    user = db.find_user_by_email(email)
    if user:
        send_reset_email(user)
    
    # ALWAYS return this, whether or not the user exists
    return 200, "If an account exists, a reset email has been sent"

An attacker can't enumerate accounts because the response is identical.

If reset tokens are predictable or long-lived, attackers can brute-force them.

Weak token:

# WRONG — predictable (sequential, guessable)
token = str(time.time())  # "1692921600.123"

Attack:

1. Attacker requests a reset for alice@example.com
2. Attacker gets token "1692921600.123"
3. Attacker guesses the next token: "1692921600.124", "1692921600.125", ...
4. One of the guesses works, and attacker resets Alice's password

Defense: strong, expiring tokens

import secrets
 
# CORRECT — cryptographically random, 32 bytes
token = secrets.token_urlsafe(32)
 
# Store with expiry
db.save_reset_token(email, token, expires_at=now() + 15min)
 
# Verify on reset
@app.post('/reset-password')
def reset_password():
    token = request.json.get('token')
    new_password = request.json.get('password')
    
    reset = db.get_reset_token(token)
    if not reset or reset.expires_at < now():
        return 400  # token invalid or expired
    
    user = db.get_user_by_id(reset.user_id)
    user.password_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt())
    db.delete_reset_token(token)
    return 200

Strong tokens are unguessable. Short expiry (15 minutes) means a leaked token is useless after expiration.

Gotcha 6: Token leakage in logs

If logs include access tokens, anyone with log access can impersonate users.

Leak:

2025-08-23 10:15:42 INFO Request: GET /api/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiMTIzIn0.abc123...

An attacker with access to logs now has the token.

Defense: sanitize logs

import logging
 
class TokenFilter(logging.Filter):
    def filter(self, record):
        # Remove tokens from log messages
        record.msg = record.msg.replace(
            r'Bearer [^ ]+', 'Bearer [REDACTED]'
        )
        return True
 
logging.getLogger().addFilter(TokenFilter())

Also:

  • Never log full credit card numbers.
  • Never log passwords (obviously).
  • Sanitize personally identifiable information (PII).

Gotcha 7: Race conditions in concurrent auth checks

If two simultaneous requests check the same token before either invalidates it, both might pass.

Race condition:

Request 1: GET /profile (token: xyz)
  Check: is xyz valid? → YES
  Lookup user...

Request 2: GET /profile (token: xyz)
  Check: is xyz valid? → YES
  Lookup user...

Request 1: Invalidate token (mark as used)
Request 2: Invalidate token (already invalidated, but both passed the check)

This is uncommon with stateless JWTs (they can't be "used up"), but occurs with one-time tokens (OAuth codes).

Defense: atomic invalidation

# WRONG — separate check and invalidate steps
@app.post('/exchange-code')
def exchange_code():
    code = request.json.get('code')
    
    if db.find_code(code):  # step 1: check
        token = generate_token()
        db.delete_code(code)  # step 2: invalidate
        return {'token': token}
    return 400
 
# CORRECT — atomic update in one query
@app.post('/exchange-code')
def exchange_code():
    code = request.json.get('code')
    
    # Atomic: delete and return in one step
    result = db.execute(
        'DELETE FROM oauth_codes WHERE code = ? RETURNING user_id',
        code
    )
    
    if not result:
        return 400  # code doesn't exist (already used or never existed)
    
    user_id = result[0]['user_id']
    token = generate_token()
    return {'token': token}

The database ensures only one request deletes the code; others get no result.

Common patterns for secure auth

  1. Always regenerate after login: new session ID, new JWT, whatever the credential is.
  2. Set short expiries: access tokens 15 min, sessions 24h, password resets 15 min.
  3. Use SameSite=Strict for cookies.
  4. Invalidate old tokens when password changes or account is compromised.
  5. Log security events (login, password change, failed attempts).
  6. Monitor for abuse (rapid failed logins, impossible travel, unusual locations).
  7. Educate users: phishing is still the #1 auth vulnerability.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What is a session fixation attack?

2. What does the SameSite=Strict cookie flag prevent?

3. Why should you use hmac.compare_digest() instead of == for password checking?

4. What is account enumeration?