GUIDE · OWASP Top 10:2025

A07:2025 Authentication Failures

Failures in confirming identity and managing the resulting session — attackers log in as someone else or stay logged in.

lesson5 sectionsnode only2021: A07 Identification and Authentication Failures21 asserts162 lines

At a glance

WHAT
Failures in confirming identity and managing the resulting session — attackers log in as someone else or stay logged in.
HAPPENS
credential stuffing (breached user:pass lists replayed); brute force (no lockout/rate limit); weak password policy; session fixation (attacker sets the session id before victim logs in); no expiry / no rotation; MFA bypass (skip step, response tamper, OTP brute force); reset-token flaws (predictable, non-expiring, reusable, leaked via Referer); predictable session ids; token in localStorage (XSS-readable); user enumeration via error text / timing; "remember me" forever.
SPOT
login with no limiter; session id unchanged across login; Math.random / Date.now in token code; cookie without HttpOnly/ Secure/SameSite; reset token stored in plaintext; different error for "no such user" vs "bad password"; JWT without exp.
FIX
rate limit + lockout with backoff (per account AND per IP); breached-password check (HIBP k-anonymity API); length-first password policy (NIST 800-63B: 8+ min, 64+ max, no composition rules, no forced rotation); MFA (TOTP/WebAuthn/passkeys); rotate session id on login & privilege change; absolute + idle expiry; cookie flags HttpOnly Secure SameSite; CSPRNG tokens, hashed at rest, single-use, short TTL; constant-time compare; generic errors.
SAY
CWE-287 Improper Authentication, CWE-384 Session Fixation, CWE-307 Improper Restriction of Excessive Auth Attempts, CWE-521 Weak Password Requirements, CWE-613 Insufficient Session Expiration, CWE-640 Weak Password Recovery. Pitch: "Rate-limit, MFA, rotate the session on login, HttpOnly cookies, short-lived single-use tokens compared in constant time."

The idea#

Picture a hall pass scribbled on a sticky note. You can photocopy it. Nothing on it says when it dies. Ask the office about a stranger and the answer changes: "no student by that name" for one, "wrong birthday" for another. That difference is how you learn who goes to the school. The pass was supposed to mean a person. It means a piece of paper.

That is an Authentication Failure. The program is weak at proving who someone is, or weak at the session after a yes. Count login tries for the account and the address, and grow the wait after a few free failures. Judge a password by length, and reject breached passwords. On login, throw the old session id away and mint a new one with crypto.randomBytes. End the session on idle time and again at an absolute deadline. Mark the cookie HttpOnly, Secure, and SameSite, under the __Host- prefix. Store a reset token as a hash of 32 random bytes, use it once, expire it quickly, and compare with timingSafeEqual. Show one sentence whether or not the account exists.

Failure looks like the sticky note. Guesses are unlimited, so a list of passwords from somewhere else can be replayed all night. The session id stays the same from the anonymous visit through login, and a planted id becomes the person's id. The token is the clock. Script on the page can read the cookie. The reset token sits in the database in the clear, works more than once, or still works next week. The error text tells a stranger which emails are registered.

Why it matters#

The front door draws scripts and careful tries. On the 2025 OWASP list the name is Authentication Failures. The damage is someone else's account, or a session that outlives the visit. Stuffing replays passwords from other breaches. Fixation reuses an id you forgot to rotate. A reset link that lives too long is a second password sitting in a mailbox.

Count failures, then back off. Check a breach list, and let length carry the password rule. Add a second factor when you can, and limit guesses of that code. Rotate the session at login and when privileges change. Set idle and absolute timeouts. Keep the cookie unreadable to script and limited to HTTPS. Hash reset tokens, mark them used, expire them in minutes, and compare in constant time. Use one sentence, and the same timing, for every reset response.

A small example#

JS
const crypto = require('node:crypto'); function issueReset(now, ttlMs) {  const token = crypto.randomBytes(32).toString('base64url');  const hash = crypto.createHash('sha256').update(token).digest('hex');  return { token, record: { hash, exp: now + ttlMs, used: false } };} function matches(token, record, now) {  if (record.used || now > record.exp) return false;  const hash = crypto.createHash('sha256').update(String(token)).digest('hex');  const left = Buffer.from(hash);  const right = Buffer.from(record.hash);  if (left.length !== right.length) return false;  return crypto.timingSafeEqual(left, right);} const issued = issueReset(1000, 60000);const ok = matches(issued.token, issued.record, 2000);

You would email issued.token and store issued.record. ok is true inside the window, before anyone marks the record used. The compare is on the hashes.

What the code below is doing#

Five sections. Identity first, then the session after a yes.

Lockout is LoginGuard. check allows the try until lockedUntil. Past maxFree, fail grows the lock as a power of two up to capMs. success clears the row. The asserts show a one-second lock, then a two-second lock. Key the guard on the account and the address.

Password policy is passwordOk: a string of length 12 to 128, absent from a tiny breached set. A long passphrase passes. short and Password fail. Length is the rule. The breach list sits beside it.

Sessions are SessionStore. Ids are 32 random bytes. login deletes the pre-login id and creates another, keeping harmless state such as the cart. get drops the session when idle time or absolute time is past. The asserts show the old id dead, idle expiry, and absolute expiry even when the session keeps getting touched.

Cookie flags are the frozen sessionCookie: name __Host-sid, httpOnly, secure, sameSite of Lax, path /, and a maxAge lined up with the idle window. Page script can read web storage. It cannot read this cookie.

Reset tokens are issueReset and consumeReset. The map key is the SHA-256 of the token. Consume compares with timingSafeEqual, refuses a used or expired row, then marks it used. A second try, a late try, and a wrong guess return null. Either way you say a message was sent if the account exists.

When you talk about it, say this: limit the tries, rotate the session when login succeeds, keep the cookie unreadable to script, and make reset tokens random, hashed, single-use, and short.

Everything below is the demonstration. Same idea, written as code you can run.

JSowasp/a07-authentication-failures.js · setup
'use strict';const assert = require('node:assert/strict');const crypto = require('node:crypto');

Not run in the browser: uses node:crypto. Copy the node command and run it locally.

01

Brute force / credential stuffing: lockout with exponential backoff#

JS
// VULNERABLE://   app.post('/login', async (req, res) => { if (await check(req.body)) ... });   // unlimited attempts// FIXED:class LoginGuard {  constructor({ maxFree = 5, baseMs = 1000, capMs = 15 * 60_000 } = {}) {    this.maxFree = maxFree; this.baseMs = baseMs; this.capMs = capMs; this.state = new Map();  }  check(key, now) {    const s = this.state.get(key);    return !s || now >= s.lockedUntil;  }  fail(key, now) {    const s = this.state.get(key) || { fails: 0, lockedUntil: 0 };    s.fails += 1;    if (s.fails > this.maxFree) s.lockedUntil = now + Math.min(this.capMs, this.baseMs * 2 ** (s.fails - this.maxFree - 1));    this.state.set(key, s);  }  success(key) { this.state.delete(key); }}const guard = new LoginGuard();let now = 1_000_000;for (let i = 0; i < 5; i++) { assert.equal(guard.check('acct:alice', now), true); guard.fail('acct:alice', now); }guard.fail('acct:alice', now);                                    // 6th fail => locked 1sguard.fail('acct:alice', now + 1000);                             // 7th => 2sguard.success('acct:alice');// Key on BOTH account and IP; also check HIBP range API: GET /range/{sha1[0..5]} -> compare suffixes locally.
Verified by 5 asserts
JS
assert.equal(guard.check('acct:alice', now), false);assert.equal(guard.check('acct:alice', now + 1000), true);assert.equal(guard.check('acct:alice', now + 2000), false);assert.equal(guard.check('acct:alice', now + 3000), true);assert.equal(guard.check('acct:alice', now), true);
02

Password policy (NIST 800-63B shape: length, breach list, no silly composition rules)#

JS
const breached = new Set(['password', '123456', 'qwerty', 'letmein']);   // stand-in for HIBPfunction passwordOk(pw) {  return typeof pw === 'string' && pw.length >= 12 && pw.length <= 128 && !breached.has(pw.toLowerCase());}
Verified by 3 asserts
JS
assert.equal(passwordOk('correct horse battery staple'), true);assert.equal(passwordOk('short'), false);assert.equal(passwordOk('Password'), false);
03

Session store: rotation on login, idle + absolute expiry, CSPRNG ids#

JS
// VULNERABLE://   req.session.user = user;                   // same session id before and after login => fixation//   cookie: 'sid=' + Date.now()                // predictable; no expiry; no flags// FIXED:class SessionStore {  constructor({ idleMs = 30 * 60_000, absoluteMs = 12 * 3_600_000 } = {}) {    this.idleMs = idleMs; this.absoluteMs = absoluteMs; this.sessions = new Map();  }  newId() { return crypto.randomBytes(32).toString('base64url'); }  create(data, now) {    const id = this.newId();    this.sessions.set(id, { ...data, createdAt: now, lastSeen: now });    return id;  }  get(id, now) {    const s = this.sessions.get(id);    if (!s) return null;    if (now - s.lastSeen > this.idleMs || now - s.createdAt > this.absoluteMs) { this.sessions.delete(id); return null; }    s.lastSeen = now;    return s;  }  login(oldId, user, now) {                    // rotate: destroy pre-auth session, mint a new id    const prev = this.sessions.get(oldId);    this.sessions.delete(oldId);    return this.create({ ...(prev ? { cart: prev.cart } : {}), userId: user.id }, now);  }  destroy(id) { this.sessions.delete(id); }}const store = new SessionStore();let t = 5_000_000;const anonId = store.create({ cart: ['sku1'] }, t);const authId = store.login(anonId, { id: 'u1' }, t);const longId = store.create({ userId: 'u2' }, t);for (let i = 1; i <= 13; i++) store.get(longId, t + i * 3_600_000 - 60_000);   // keeps touching it
Verified by 7 asserts
JS
assert.notEqual(authId, anonId);                                   // id rotated on loginassert.equal(store.get(anonId, t), null);                          // old id dead => fixation defeatedassert.equal(store.get(authId, t).userId, 'u1');assert.deepEqual(store.get(authId, t).cart, ['sku1']);             // carried state overassert.equal(Buffer.from(authId, 'base64url').length, 32);        // 256-bit random idassert.equal(store.get(authId, t + 31 * 60_000), null);            // idle expiryassert.equal(store.get(longId, t + 13 * 3_600_000), null);        // absolute expiry wins anyway
04

Cookie attributes (as an object; localStorage is XSS-readable, cookies with HttpOnly are not)#

JS
const sessionCookie = Object.freeze({  name: '__Host-sid',          // __Host- prefix: Secure, no Domain, Path=/  httpOnly: true,              // JS cannot read it  secure: true,                // HTTPS only  sameSite: 'Lax',             // 'Strict' if no cross-site top-level nav needed; CSRF mitigation  path: '/',  maxAge: 30 * 60,             // seconds; match idle timeout});
Verified by 2 asserts
JS
assert.equal(sessionCookie.httpOnly && sessionCookie.secure, true);assert.ok(['Lax', 'Strict'].includes(sessionCookie.sameSite));
05

Password reset token: random, hashed at rest, single-use, short TTL, constant-time compare#

JS
// VULNERABLE: token = user.id + Date.now(); stored plaintext; never expires; reusable; emailed in a GET link w/o Referrer-Policy.// FIXED:const resetTokens = new Map();   // sha256(token) -> { userId, exp, used }function issueReset(userId, now, ttlMs = 15 * 60_000) {  const token = crypto.randomBytes(32).toString('base64url');  resetTokens.set(crypto.createHash('sha256').update(token).digest('hex'), { userId, exp: now + ttlMs, used: false });  return token;                 // only the user ever sees the raw token}function consumeReset(token, now) {  const h = crypto.createHash('sha256').update(String(token)).digest('hex');  for (const [k, rec] of resetTokens) {    if (k.length === h.length && crypto.timingSafeEqual(Buffer.from(k), Buffer.from(h))) {      if (rec.used || now > rec.exp) return null;      rec.used = true;      return rec.userId;    }  }  return null;}const rt = issueReset('u1', t);const rt2 = issueReset('u1', t);// Generic responses: "If that account exists, we sent an email." Same timing for both branches.
Verified by 4 asserts
JS
assert.equal(consumeReset(rt, t + 60_000), 'u1');assert.equal(consumeReset(rt, t + 61_000), null);                  // single-useassert.equal(consumeReset(rt2, t + 16 * 60_000), null);            // expiredassert.equal(consumeReset('guess', t), null);

See also