Back

A7:2021 – Identification and Authentication Failures: Prevention

Defense in Layers

No single control prevents authentication failures. The attack vectors target different links in the identity chain, so the defenses must too. Think of the following eight layers as complementary—each one closes a door the others leave open.

Registration -> strong length-based policy + breached-password screen
Storage      -> Argon2id / bcrypt, per-user salt
Login        -> rate limit + backoff, uniform responses, MFA
Session       -> CSPRNG ID, rotate on login, HttpOnly+Secure+SameSite, timeouts
Recovery     -> high-entropy single-use tokens, no enumeration, re-auth
Tokens       -> pin alg, verify signature + exp/iss/aud, short lifetime + revocation

Layer 1: Password Policy (NIST 800-63B)

Modern guidance (NIST SP 800-63B) reverses decades of counterproductive rules. Favor length; drop mandatory composition and periodic rotation.

DoDon't
Require a minimum of 8, allow at least 64 charactersCap length at 16 or truncate silently
Allow all Unicode, spaces, and pasteForce uppercase + symbol + number composition
Screen against breached-password listsForce a reset every 90 days "just because"
Only force a reset on evidence of compromiseUse knowledge-based "security questions"
// Node.js — length-first validation (Express)
function validatePassword(pw) {
  const errors = [];
  if (typeof pw !== 'string') errors.push('Password required');
  // NIST: length is the primary strength lever
  if (pw.length < 8)   errors.push('Use at least 8 characters');
  if (pw.length > 64)  errors.push('Keep it under 64 characters');
  // Do NOT impose composition rules; DO block obvious weak values
  if (/^(.)\1+$/.test(pw)) errors.push('Not all the same character');
  return { ok: errors.length === 0, errors };
}

Encourage passphrases ("correct horse battery staple"), support password managers by allowing paste, and show a strength meter instead of nagging composition rules.

Layer 2: Breached-Password Screening

The single highest-impact control against credential stuffing on your own side is refusing passwords already known to be compromised. Use the k-anonymity range API model so the full password (or full hash) never leaves your server.

// Node.js — k-anonymity breached-password check (HaveIBeenPwned-style range model)
const crypto = require('crypto');

async function isBreached(password) {
  const sha1 = crypto.createHash('sha1')
    .update(password).digest('hex').toUpperCase();
  const prefix = sha1.slice(0, 5);      // only the first 5 hex chars are sent
  const suffix = sha1.slice(5);

  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
  const body = await res.text();        // list of "SUFFIX:count" lines
  return body.split('\n').some(line => line.split(':')[0].trim() === suffix);
}

// On registration and on password change:
if (await isBreached(newPassword)) {
  return res.status(400).json({ error: 'This password has appeared in a data breach. Choose another.' });
}

Layer 3: Secure Password Storage

Store passwords with a slow, salted, memory-hard hash. Argon2id is the current first choice; bcrypt and scrypt are acceptable. Never use MD5, SHA-1, or plain SHA-256 for passwords, and never encrypt (reversible) what should be hashed.

# Python — Argon2id (argon2-cffi)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher(          # sensible, tunable defaults
    time_cost=3, memory_cost=64 * 1024, parallelism=4
)

def hash_password(pw: str) -> str:
    return ph.hash(pw)         # salt is generated and embedded automatically

def verify_password(stored_hash: str, pw: str) -> bool:
    try:
        ph.verify(stored_hash, pw)
        # transparently upgrade parameters over time
        if ph.check_needs_rehash(stored_hash):
            # re-hash and persist the new value
            pass
        return True
    except VerifyMismatchError:
        return False

Layer 4: Anti-Automation (Rate Limiting & Lockout)

Slow attackers down without creating a denial-of-service against real users. Combine per-IP, per-account, and per-credential throttling, plus exponential backoff. Remember: per-account lockout alone does not stop password spraying.

// Node.js — layered login throttling (express-rate-limit)
const rateLimit = require('express-rate-limit');

// Per-IP: blunt cap on request volume
const ipLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,       // 15 minutes
  max: 100,
  standardHeaders: true,
  message: { error: 'Too many requests, try again later.' }
});

// Per-account: stricter, keyed on the submitted username
const accountLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  keyGenerator: (req) => `login:${(req.body.email || '').toLowerCase()}`,
  handler: (req, res) => res.status(429)
    .json({ error: 'Too many attempts. Try again shortly.' })
});

app.post('/login', ipLimiter, accountLimiter, loginHandler);

Also monitor for the spray pattern (one credential/IP touching many accounts) at the detection layer, add CAPTCHA/proof-of-work only after suspicious behavior, and prefer temporary backoff over permanent lockout to avoid abuse-driven DoS.

Layer 5: Multi-Factor Authentication

MFA is the most effective single control against stolen and stuffed credentials. Prefer phishing-resistant factors, and enforce MFA on every authentication path.

FactorStrengthNotes
FIDO2 / WebAuthn (passkeys, security keys)StrongestPhishing-resistant; bound to the origin
TOTP authenticator appGoodBetter than SMS; still phishable via real-time proxy
Push with number-matchingGoodNumber-matching defeats prompt bombing
SMS / email OTPWeakSIM-swap and interception risk; last resort only
# Python — verify a TOTP second factor (pyotp), rate-limited
import pyotp

def verify_totp(user, submitted_code):
    if otp_attempts_exceeded(user.id):      # rate-limit the 2nd factor too
        raise TooManyAttempts()
    totp = pyotp.TOTP(user.totp_secret)     # secret stored encrypted
    # valid_window=1 tolerates minor clock skew; do not widen further
    if totp.verify(submitted_code, valid_window=1):
        reset_otp_attempts(user.id)
        return True
    increment_otp_attempts(user.id)
    return False

Do not let account recovery downgrade the factor: if a user enrolled a passkey, recovery must not silently fall back to an emailed link that an attacker can intercept.

Layer 6: Secure Session Management

Whether you use server-side sessions or tokens, the identity binding must be unguessable, rotated at login, hardened in transport, and revocable.

Generate high-entropy IDs from a CSPRNG

// Node.js — 256-bit session identifier
const crypto = require('crypto');
const sessionId = crypto.randomBytes(32).toString('hex'); // 256 bits of entropy
// NEVER: Math.random(), timestamps, sequential counters, or md5(user+time)

Regenerate the ID at login (prevents fixation) and set hardened cookies

// Node.js — express-session
const session = require('express-session');

app.use(session({
  name: 'sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,                 // block JS access (XSS token theft)
    secure:   true,                 // HTTPS only
    sameSite: 'lax',                // mitigate cross-site sending
    maxAge:   30 * 60 * 1000        // 30-minute idle window
  }
}));

app.post('/login', async (req, res) => {
  const user = await authenticate(req.body);
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  // CRITICAL: rotate the session ID on privilege change
  req.session.regenerate((err) => {
    if (err) return res.status(500).end();
    req.session.userId = user.id;
    req.session.createdAt = Date.now();     // for absolute-timeout checks
    res.json({ ok: true });
  });
});

Enforce idle and absolute timeouts, and invalidate server-side on logout

// Absolute timeout (independent of activity)
const ABSOLUTE_MS = 8 * 60 * 60 * 1000; // 8 hours
app.use((req, res, next) => {
  if (req.session.userId &&
      Date.now() - req.session.createdAt > ABSOLUTE_MS) {
    return req.session.destroy(() => res.status(401).json({ error: 'Session expired' }));
  }
  next();
});

// Real logout: destroy server-side, then clear the cookie
app.post('/logout', (req, res) => {
  req.session.destroy(() => {
    res.clearCookie('sid');
    res.json({ ok: true });
  });
});

Session management essentials, summarized:

Layer 7: Non-Enumerable Responses & Recovery

Make "account exists" and "account does not exist" indistinguishable in body, status code, and timing—on login, registration, and password reset alike.

# Python (Flask) — uniform login response + constant-time behavior
import secrets
from argon2 import PasswordHasher
ph = PasswordHasher()
# A pre-computed dummy hash so we spend the same time whether or not the user exists
DUMMY_HASH = ph.hash(secrets.token_hex(16))

@app.post('/login')
def login():
    user = get_user(request.form['email'])
    stored = user.password_hash if user else DUMMY_HASH
    try:
        ph.verify(stored, request.form['password'])
        valid = user is not None            # dummy never matches a real password
    except Exception:
        valid = False

    if not valid:
        # Identical message + status for unknown user and wrong password
        return {'error': 'Invalid email or password'}, 401
    return start_session(user)
# Python (Flask) — non-enumerable password reset
@app.post('/forgot')
def forgot():
    user = get_user(request.form['email'])
    if user:
        token = secrets.token_urlsafe(32)   # high-entropy, single-use
        store_reset_token(user.id, token, ttl_minutes=15)  # short-lived
        send_reset_email(user.email, token)
    # ALWAYS return the same response, whether or not the account exists:
    return {'message': 'If that email is registered, a reset link has been sent.'}, 200

Recovery rules: reset tokens must be high-entropy, single-use, short-lived, and bound to the account; a completed reset must invalidate all existing sessions; and never use guessable "security questions."

Layer 8: Correct Token (JWT) Validation

If you issue JWTs or other bearer tokens, verify them strictly. The recurring mistake is letting the token's own header decide how it is validated.

// Java (Spring Security / jjwt) — strict JWT validation
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.security.Keys;

public Claims validate(String jwt) {
    return Jwts.parserBuilder()
        // Pin the key; the library will NOT accept alg:none or a mismatched alg
        .setSigningKey(Keys.hmacShaKeyFor(secretBytes)) // or a fixed RSA public key
        .requireIssuer("https://auth.example.com")      // validate iss
        .requireAudience("example-api")                 // validate aud
        .setAllowedClockSkewSeconds(30)                 // small skew only
        .build()
        .parseClaimsJws(jwt)      // throws on bad signature, expiry, iss/aud mismatch
        .getBody();
}
// Node.js — jsonwebtoken with an explicit algorithm allow-list
const jwt = require('jsonwebtoken');

function verifyToken(token) {
  return jwt.verify(token, PUBLIC_KEY, {
    algorithms: ['RS256'],          // PIN it — never trust the token's header
    issuer:   'https://auth.example.com',
    audience: 'example-api',
    clockTolerance: 30              // seconds
  }); // throws on invalid signature, alg:none, expiry, or claim mismatch
}

Token rules: pin the algorithm server-side, verify the signature against a trusted key, validate exp/iss/aud, keep access-token lifetimes short, and maintain a revocation/denylist (or rotating refresh tokens) so a stolen token cannot be used indefinitely.

Implementation Checklist

Next Steps

---

Part of the OWASP Top 10 Educational Repository