Skip to content
Lesson 4 of 8

Broken Authentication and Sessions

10 min read

The Problem of Identity

Authentication is the process of verifying that a user is who they claim to be. Session management is what keeps that identity across requests. When either one fails, an attacker can impersonate another user, and all the other security controls become irrelevant. The OWASP Top 10 groups these failures under A07:2021 — Identification and Authentication Failures (formerly "Broken Authentication").

These failures are especially dangerous because they attack the application's trust boundary directly. It does not matter how well protected your data is if an attacker can simply log in as a legitimate user, or worse, as an administrator. That is why authentication and session management deserve careful design and constant review. This lesson takes a defensive approach: for each weakness we first show a minimal example of vulnerable code and then its fixed version, so the focus is always on the fix.

Brute Force and Credential Stuffing

The most direct attack against authentication is brute force: trying many username and password combinations until one works. A more efficient variant is the dictionary attack, which tries common, well-known passwords instead of every possible combination. If the application does not limit attempts, an automated attacker can try millions of passwords against a single login endpoint without anyone noticing.

Credential stuffing is today one of the most prevalent threats, and it is worth understanding why. It is not about guessing passwords at random, but about reusing credentials that are already valid. The mechanism rests on two facts: first, people reuse the same password across multiple services; second, huge lists of credentials exposed in data breaches exist in the wild. Over the years, large-scale breaches have leaked hundreds of millions of email and password combinations that circulate freely and are aggregated into consolidated collections. The attacker takes one of those lists and tries it en masse and in an automated fashion against your application. Since each credential is valid somewhere, it only takes a fraction of your users having reused their password for a percentage of the attempts to succeed. That is why credential stuffing achieves hit rates that pure brute force never reaches, and why a "strong" password offers no protection if the user reused it on a service that was already compromised.

The real impact goes beyond access to a single account: mass account takeovers, fraud, theft of personal data, and considerable reputational damage when users discover their account was accessed. The defense cannot be a single measure, but a combination: rate limiting, temporary lockout after several failures, detection of already-compromised credentials, and MFA. We look at these in detail below.

Secure Password Storage

The root cause of many breaches is not how passwords are stolen, but how they were stored when they were stolen. Storing passwords in plain text, or hashing them with fast functions like MD5 or SHA-1, means that a database leak hands over the passwords almost immediately: MD5 and SHA-1 are so fast that an attacker can compute billions of hashes per second on commodity hardware and reverse most of them with precomputed tables (rainbow tables).

Vulnerable — fast, unsalted hash, trivial to reverse:

import crypto from 'node:crypto';

function hashPassword(password) {
  // MD5 is extremely fast to brute-force and carries no salt: do NOT use
  return crypto.createHash('md5').update(password).digest('hex');
}

Secure — slow, salted-by-design hashing function (bcrypt):

import bcrypt from 'bcrypt';

const COST_FACTOR = 12; // deliberately expensive work

async function hashPassword(password) {
  // bcrypt generates a unique salt that is embedded in the resulting hash
  return bcrypt.hash(password, COST_FACTOR);
}

async function verifyPassword(password, storedHash) {
  // constant-time comparison against timing attacks
  return bcrypt.compare(password, storedHash);
}

Passwords must never be stored in plain text or with reversible encryption. They must be stored using a password-specific hashing function — bcrypt, scrypt, or Argon2 (today the preferred choice, winner of the Password Hashing Competition) — which are deliberately slow and resistant to attacks with specialized hardware (GPUs and ASICs). Each password carries a unique salt that prevents the use of precomputed tables; in bcrypt and Argon2 the salt is generated and embedded automatically in the resulting hash. Tune the cost factor (or the memory and time parameters in Argon2) as high as your hardware tolerates without degrading the login experience.

Rate Limiting and Account Lockout

A login with no cap on attempts is an open invitation to brute force and credential stuffing. The baseline defense is to limit how many attempts are accepted per identity and per origin within a time window.

Vulnerable — no attempt limit at all:

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await users.findByEmail(email);
  if (user && await verifyPassword(password, user.hash)) {
    return res.json({ ok: true });
  }
  return res.status(401).json({ ok: false });
});

Secure — rate limiting over the login endpoint:

import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15-minute window
  max: 5,                   // max 5 attempts per IP within the window
  standardHeaders: true,
  legacyHeaders: false,
  message: { ok: false, error: 'Too many attempts, try again later' },
});

app.post('/login', loginLimiter, async (req, res) => {
  const { email, password } = req.body;
  const user = await users.findByEmail(email);
  if (user && await verifyPassword(password, user.hash)) {
    return res.json({ ok: true });
  }
  return res.status(401).json({ ok: false });
});

Combine per-IP rate limiting with a temporary per-account lockout: after several consecutive failures against the same user, introduce a growing delay (exponential backoff) or a lockout of a few minutes, and notify the account owner. Watch out for two nuances: attackers distribute their attempts across thousands of IPs to evade per-origin limits (which is why the per-account limit matters), and a poorly designed lockout can turn into a denial of service against legitimate users. In the face of suspicious behavior, a CAPTCHA as an intermediate step helps curb automation without punishing the normal user.

Detecting Compromised Credentials

Since credential stuffing uses already-leaked passwords, a very effective defense is to reject passwords known to be compromised at registration and at password change. Services like Have I Been Pwned expose this check in a privacy-preserving way through k-anonymity: your application computes the SHA-1 hash of the password, sends only the first five characters of the hash to the service, receives all the suffixes that start with that prefix, and compares locally whether your password's suffix is in the list. This way the service never sees the password or its full hash, and your application still learns whether that password appears in known breaches without exposing it. Rejecting those passwords breaks the economics of credential stuffing on your attack surface.

Secure Session Handling

Once the user authenticates, the application issues a session identifier. If that identifier is compromised, the attacker hijacks the session with no need for the password. There are several classic mistakes to avoid. Session fixation occurs when the application does not generate a new identifier after login, allowing an attacker to fix a known value before the victim logs in and then reuse that same, now-authenticated identifier.

Vulnerable — the pre-login session id is kept:

app.post('/login', async (req, res) => {
  const user = await authenticate(req.body);
  if (!user) return res.status(401).json({ ok: false });
  // the anonymous session id is reused after authenticating: fixation possible
  req.session.userId = user.id;
  res.json({ ok: true });
});

Secure — the session is regenerated after authenticating:

app.post('/login', async (req, res) => {
  const user = await authenticate(req.body);
  if (!user) return res.status(401).json({ ok: false });
  // new session id: invalidates any value fixed by the attacker
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ ok: false });
    req.session.userId = user.id;
    res.json({ ok: true });
  });
});

For secure handling: generate long, random session identifiers with a cryptographically secure generator, regenerate the identifier after each authentication, always transmit it over HTTPS, and configure cookies with the HttpOnly, Secure, and SameSite attributes. Implement session expiration on inactivity and a logout that truly invalidates the session on the server, not just clears the cookie in the browser. These details, described in lesson 1, are the difference between a robust session and a trivially hijackable one.

Multi-Factor Authentication (MFA)

The most effective measure to reduce the impact of stolen credentials is multi-factor authentication (MFA). By requiring a second factor — a TOTP code app, a FIDO2/WebAuthn security key, or similar — in addition to the password, even a compromised password is not enough to get in. MFA on its own neutralizes most credential stuffing and password phishing attacks: even if the attacker has the correct credential from a breach, they do not possess the second factor.

Factors are not equal in robustness. TOTP codes from an authenticator app are a big step up from password-only, but they remain susceptible to real-time phishing. FIDO2/WebAuthn keys resist phishing because the factor is cryptographically bound to the legitimate domain. SMS codes are the weakest factor (susceptible to SIM swapping) and should only be used as a last resort. At a minimum, MFA must be mandatory for all administrative accounts, where the impact of an account takeover is greatest.

Designing Robust Authentication

Building secure authentication from scratch is difficult and error-prone, so the general recommendation is to lean on proven solutions: mature authentication frameworks, identity providers, and standards like OAuth 2.0 and OpenID Connect, rather than inventing your own scheme. These systems already correctly solve token management, expiration, rotation, and much of the edge-case handling that a homegrown build tends to overlook.

Complement the design with good, evidence-based password policies. Modern guidance (such as NIST's) recommends requiring long passwords — favoring passphrases — instead of arbitrary complexity rules that push users toward predictable patterns. Check new passwords against lists of leaked passwords, offer secure account recovery that does not reveal whether a user exists (generic error messages, uniform response times), and log and monitor failed login attempts to detect attacks in progress.

To validate your defenses, use tools like Burp Suite to analyze the login flow, token management, and the session lifecycle (for example, to verify that the id is really regenerated after login and invalidated on logout). As a normative reference, lean on OWASP's authentication guidance: the Authentication Cheat Sheet, the Session Management Cheat Sheet, and the ASVS standard, which provide verifiable requirements point by point. Authentication is the front door: it is worth protecting it rigorously.

Prevention Checklist

  • [ ] Hash passwords with Argon2, bcrypt, or scrypt; never MD5, SHA-1, or plain text.
  • [ ] Use a unique salt per password (bcrypt and Argon2 include it automatically) and a high cost factor.
  • [ ] Apply rate limiting per IP on the login endpoint and on account recovery.
  • [ ] Add temporary lockout or growing backoff per account after repeated failures, avoiding creating a DoS.
  • [ ] Reject passwords known to be compromised (k-anonymity check in the HIBP style).
  • [ ] Regenerate the session identifier after authenticating to prevent session fixation.
  • [ ] Issue long, random session IDs with a cryptographically secure generator.
  • [ ] Configure cookies with HttpOnly, Secure, and SameSite, always serve over HTTPS.
  • [ ] Implement inactivity expiration and a logout that invalidates the session on the server.
  • [ ] Require MFA (TOTP or, preferably, FIDO2/WebAuthn) and make it mandatory for administrators.
  • [ ] Prefer long passwords or passphrases over arbitrary complexity rules.
  • [ ] Lean on OAuth 2.0 / OpenID Connect and mature frameworks instead of homegrown schemes.
  • [ ] Use generic error messages so you do not reveal whether an account exists.
  • [ ] Log and monitor failed attempts; audit the flow with Burp Suite and check against OWASP ASVS and the cheat sheets.