OWASP A07 Authentication Failures: MFA, Passkeys, and Password Rules That Actually Work in 2026

Authentication is where most apps get compromised. Not through exotic zero-days or nation-state tooling — through login pages that accept password123, session tokens that never expire, and MFA implementations that a SIM-swapper can bypass in 20 minutes.

OWASP A07 (Authentication Failures) has sat in the top 10 since the list existed. It dropped from A02 in 2017 to A07 in 2021, which people took as a good sign. It wasn’t. The category shrank in prevalence because credential stuffing got so industrial that everyone got hit and started paying attention. The underlying problems are the same.

This article skips the theory and gets into what you should actually build in 2026: which MFA methods are worth deploying, how to implement passkeys without building a PhD thesis in WebAuthn, and what password rules NIST actually recommends versus the cargo-cult rules that have been making users miserable for 20 years.


What A07 Actually Covers

People hear "authentication failures" and think "weak passwords." That’s one slice. The full category includes:

  • Credential stuffing — automated login attempts using breach dumps
  • Brute force — still works on anything without rate limiting
  • Default credentials — IoT, databases, admin panels, you know the list
  • Broken session management — tokens that never expire, predictable session IDs
  • Missing or bypassable MFA
  • Insecure "forgot password" flows — security questions, email-only with no expiry
  • Passwords stored in plaintext or with weak hashing (MD5, SHA-1 without salt)

If any of those land, the attacker is in as a legitimate user. Your WAF won’t catch it. Your SIEM will log a successful login. Good luck with your incident response.


Password Rules: Stop Punishing Your Users

Let’s kill the myth first. The traditional enterprise password policy — at least 8 characters, upper + lower + number + symbol, expires every 90 days, can’t reuse last 12 — was invented in 2003 by Bill Burr at NIST. Burr himself said in 2017 that it was a mistake. Users respond to complexity rules by writing Password1! and incrementing the number every 90 days. Attackers know this.

What NIST SP 800-63B actually says now:

  • Minimum 8 characters. That’s the floor, not the target. Allow up to 64 at minimum.
  • No mandatory complexity rules. Seriously.
  • No mandatory rotation — only force a change if the credential is known compromised.
  • Block passwords against a known breach list. This matters more than any complexity rule.
  • Allow all printable ASCII and Unicode.
  • No password hints, no security questions.

The breach list check is the one rule everyone ignores and the one that actually works. A user choosing Tr0ub4dor&3 feels secure. That exact password has been in breach dumps since 2013.

Implementing breach list checking:

The simplest production approach is querying the Have I Been Pwned k-anonymity API. You send the first 5 characters of the SHA-1 hash, get back matching suffixes, check locally. The plaintext never leaves your server.

import hashlib
import httpx

def is_password_breached(password: str) -> bool:
    """
    Check against HIBP Pwned Passwords API using k-anonymity.
    Returns True if the password appears in known breach dumps.
    """
    sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
    prefix, suffix = sha1[:5], sha1[5:]

    resp = httpx.get(
        f"https://api.pwnedpasswords.com/range/{prefix}",
        headers={"Add-Padding": "true"},  # prevents traffic analysis
        timeout=3.0,
    )
    resp.raise_for_status()

    for line in resp.text.splitlines():
        hash_suffix, count = line.split(":")
        if hash_suffix == suffix:
            return int(count) > 0

    return False

Call this at registration and password change. Reject if it returns True. That’s it. More effective than any complexity regex you’ll ever write.

For offline / air-gapped environments: download the full HIBP SHA-1 hash list (currently ~900M entries, ~12GB compressed) and query it locally with a binary search. There are ready-made tools for this: pwncheck and similar. Not glamorous, but it works.


Hashing: If You’re Not Using Argon2id, Fix It Today

This shouldn’t be a 2026 discussion, but here we are. If your app is using:

  • MD5 — stop
  • SHA-1 / SHA-256 (raw, no salt) — stop
  • bcrypt with work factor 10 or lower — upgrade
  • scrypt — acceptable but Argon2id is preferred

Use Argon2id. It won the Password Hashing Competition for a reason. It’s memory-hard (making GPU/ASIC attacks expensive) and time-hard.

Recommended parameters for 2026 (per OWASP recommendations):

  • m=19456 (19 MB memory)
  • t=2 (2 iterations)
  • p=1 (1 parallel thread)
# Python — using argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher(
    time_cost=2,
    memory_cost=19456,
    parallelism=1,
    hash_len=32,
    salt_len=16,
)

def hash_password(password: str) -> str:
    return ph.hash(password)

def verify_password(stored_hash: str, password: str) -> bool:
    try:
        ph.verify(stored_hash, password)
        # Rehash if parameters have changed since original hash
        if ph.check_needs_rehash(stored_hash):
            return True, ph.hash(password)  # caller should update stored hash
        return True, None
    except VerifyMismatchError:
        return False, None

The rehash-on-login pattern matters. It lets you silently upgrade all active users to stronger parameters without forcing a password reset.


MFA: Not All Factors Are Equal

Multi-factor authentication is non-negotiable for anything that matters. But the quality of the second factor varies wildly, and some "MFA" implementations actively create a false sense of security.

SMS OTP — Legacy, Avoid If You Can

SIM swapping is trivial. SS7 vulnerabilities are real and exploited by criminals, not just state actors. SMS OTP is better than nothing, but it’s the weakest option and should be your last resort. If you must support it (user accessibility, feature phone support), rate-limit aggressively and alert on unusual country code patterns.

TOTP (Time-Based One-Time Password)

Google Authenticator, Authy, 1Password — they all implement RFC 6238. TOTP is a massive improvement over SMS. No network dependency, can’t be SIM-swapped remotely.

Gotcha: TOTP is still phishable. If an attacker builds a real-time phishing proxy, they can forward the TOTP code before it expires (30-second window). Evilginx2 and similar tools automate this. TOTP doesn’t protect against targeted phishing attacks.

Implementation with pyotp:

import pyotp
import qrcode
import io
import base64

def generate_totp_secret() -> str:
    """Generate a new TOTP secret for a user."""
    return pyotp.random_base32()

def get_totp_uri(secret: str, username: str, issuer: str = "MyApp") -> str:
    totp = pyotp.TOTP(secret)
    return totp.provisioning_uri(name=username, issuer_name=issuer)

def verify_totp(secret: str, token: str) -> bool:
    totp = pyotp.TOTP(secret)
    # valid_window=1 allows 1 step before/after for clock skew
    return totp.verify(token, valid_window=1)

def generate_qr_base64(uri: str) -> str:
    """Returns base64 PNG for embedding in an img tag."""
    qr = qrcode.make(uri)
    buf = io.BytesIO()
    qr.save(buf, format="PNG")
    return base64.b64encode(buf.getvalue()).decode()

Critical: Store the TOTP secret encrypted at rest. If your database leaks and secrets are plaintext, an attacker can generate codes indefinitely. Also implement used-token tracking — replay attacks within the 30-second window are real.

Hardware Tokens (FIDO U2F / FIDO2)

YubiKey and similar devices are phishing-resistant by design. The cryptographic response is bound to the origin domain, so a phishing proxy gets a credential that’s useless on the real site. For high-privilege accounts (admin, finance, infra access), hardware tokens should be mandatory.


Passkeys: The State of Play in 2026

Passkeys are FIDO2 credentials stored on a user’s device (or in a password manager like 1Password, Bitwarden). They use public-key cryptography — the private key never leaves the device. Authentication is a challenge-response signed locally, often with biometric confirmation.

What passkeys actually solve:

  • No shared secret → no credential database to steal
  • Origin-bound → phishing proxies get nothing useful
  • Biometric or PIN confirmation → weaker than hardware tokens but better than TOTP in practice
  • Cross-device sync (via iCloud Keychain, Google Password Manager, etc.) → actually usable by normal people

What passkeys don’t solve:

  • Account takeover via compromised device session
  • Social engineering at the support level ("I lost my passkey, please help")
  • The fallback. If your passkey recovery path is "email a magic link," you’ve just made email the authentication factor.

Implementing WebAuthn Registration

The WebAuthn spec is handled by the browser and OS. You need a server-side library. The best-maintained options in 2026:

Here’s the complete flow with @simplewebauthn/server (Node.js), since JavaScript tends to be the lingua franca for web auth examples:

Backend — Registration:

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";

const RP_NAME = "My App";
const RP_ID = "myapp.example.com";
const ORIGIN = "https://myapp.example.com";

// Step 1: Generate options for the client
async function beginRegistration(user) {
  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userID: user.id,           // Buffer or Uint8Array
    userName: user.email,
    userDisplayName: user.displayName,
    attestationType: "none",   // 'direct' if you need hardware attestation
    authenticatorSelection: {
      residentKey: "required", // Passkeys MUST be resident (discoverable)
      userVerification: "required",
    },
    supportedAlgorithmIDs: [-7, -257], // ES256, RS256
  });

  // Store challenge in session — must verify this in step 2
  await session.set("webauthn_challenge", options.challenge);

  return options;
}

// Step 2: Verify what the authenticator returned
async function finishRegistration(user, body) {
  const expectedChallenge = await session.get("webauthn_challenge");

  const verification = await verifyRegistrationResponse({
    response: body,
    expectedChallenge,
    expectedOrigin: ORIGIN,
    expectedRPID: RP_ID,
    requireUserVerification: true,
  });

  if (!verification.verified) {
    throw new Error("Registration verification failed");
  }

  const { credentialID, credentialPublicKey, counter } =
    verification.registrationInfo;

  // Persist to database
  await db.passkeys.create({
    userId: user.id,
    credentialId: Buffer.from(credentialID),
    publicKey: Buffer.from(credentialPublicKey),
    counter,                   // Used for clone detection
    deviceType: verification.registrationInfo.credentialDeviceType,
    backedUp: verification.registrationInfo.credentialBackedUp,
  });
}

Frontend — Registration:

import { startRegistration } from "@simplewebauthn/browser";

async function registerPasskey() {
  // Get options from your backend
  const optionsResponse = await fetch("/auth/passkey/register/begin");
  const options = await optionsResponse.json();

  try {
    // Browser handles the biometric/PIN prompt
    const attestationResponse = await startRegistration(options);

    // Send result to backend
    const verifyResponse = await fetch("/auth/passkey/register/finish", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(attestationResponse),
    });

    if (!verifyResponse.ok) throw new Error("Registration failed");
    console.log("Passkey registered successfully");
  } catch (err) {
    if (err.name === "InvalidStateError") {
      console.error("Passkey already registered for this device");
    } else {
      throw err;
    }
  }
}

Gotcha — the counter field: The counter in stored credentials increments with each authentication. If you receive a counter equal to or lower than the stored value, it’s a cloned authenticator. Reject it and alert. Most cloud-synced passkeys report counter 0 always (by design — synced across devices, incrementing doesn’t make sense). Log this state but don’t block on it for synced credentials. Check credentialDeviceTypemultiDevice means synced, singleDevice means hardware-bound.


Rate Limiting and Lockout: Get This Right

Brute force protection is still misimplemented constantly. The two failure modes:

Too aggressive: Lock after 5 attempts. Attacker runs a distributed DoS by locking accounts at will — no password needed.

Too loose: Lock after 100 attempts. Attacker brute-forces the top 100 most common passwords against your entire user list. With 100k users, 10M attempts, they’ll compromise ~5-10% of accounts statistically.

The right approach for 2026:

Use progressive delays with IP-based and account-based tracking separately:

import time
import redis

r = redis.Redis()

def check_login_rate_limit(username: str, ip: str) -> None:
    """Raises if rate limit exceeded. Uses exponential backoff, not hard lockout."""
    
    account_key = f"auth:account:{username}"
    ip_key = f"auth:ip:{ip}"

    account_attempts = int(r.get(account_key) or 0)
    ip_attempts = int(r.get(ip_key) or 0)

    # Per-account: after 10 failures, require CAPTCHA or slow down
    if account_attempts >= 10:
        delay = min(2 ** (account_attempts - 10), 3600)  # caps at 1 hour
        raise RateLimitError(f"Too many attempts. Try again in {delay}s", retry_after=delay)

    # Per-IP: credential stuffing detection
    if ip_attempts >= 50:
        raise RateLimitError("IP rate limited", retry_after=900)

def record_failed_login(username: str, ip: str) -> None:
    pipe = r.pipeline()
    pipe.incr(f"auth:account:{username}")
    pipe.expire(f"auth:account:{username}", 3600)
    pipe.incr(f"auth:ip:{ip}")
    pipe.expire(f"auth:ip:{ip}", 900)
    pipe.execute()

def reset_login_attempts(username: str) -> None:
    """Call this on successful login."""
    r.delete(f"auth:account:{username}")

Gotcha — username enumeration: Make sure failed login responses (wrong password vs. nonexistent account) return identical messages, response times, and HTTP status codes. Timing attacks are real — if bcrypt verification takes 100ms and "user not found" returns in 1ms, you’ve built a user enumeration oracle. Use a constant-time dummy hash check for nonexistent users.


Session Management: The Part Everyone Ignores

Solid authentication means nothing if sessions are broken. Checklist:

  • Session ID entropy: Minimum 128 bits from a CSPRNG. secrets.token_urlsafe(32) in Python. Not your user ID, not a sequential number.
  • HttpOnly + Secure + SameSite=Strict on the session cookie. Every time.
  • Session fixation protection: Regenerate session ID after login, privilege escalation, and logout.
  • Absolute session timeout: Even "remember me" sessions need a hard ceiling. 30 days max. Non-negotiable.
  • Idle timeout: 15-30 minutes for sensitive apps. Separate from absolute timeout.
  • Session invalidation on logout: Server-side. Deleting the cookie client-side is theater — the token still works if intercepted.
# Nginx hardening for session cookies (passed through to app)
# Set in your app, not nginx — this is just for reference on what attributes matter
add_header Set-Cookie "session=...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400";

Production Checklist

Before shipping anything auth-related, run through this:

Passwords:

  • Argon2id with current OWASP parameters
  • Breach list check at registration and password change
  • Minimum 8 characters, allow 64+, no forced complexity or rotation
  • Constant-time comparison for all secret values

MFA:

  • TOTP at minimum, WebAuthn/passkeys preferred
  • Backup codes generated at MFA enrollment (store as hashed list, not plaintext)
  • MFA enrollment alert via email
  • MFA bypass protection — "I can’t access my authenticator" flow must go through account verification, not just email

Sessions:

  • 128-bit+ random session IDs
  • HttpOnly, Secure, SameSite=Strict cookies
  • Session regeneration post-login
  • Server-side invalidation on logout
  • Absolute + idle timeout

Rate limiting:

  • Per-account progressive delay (not hard lockout)
  • Per-IP threshold for stuffing detection
  • Consistent response times for failed logins (no enumeration)
  • CAPTCHA trigger after N failures (consider Turnstile — no privacy nightmare unlike reCAPTCHA v2)

Monitoring:

  • Alert on: bulk failed logins, successful login after many failures, logins from new geo regions, MFA bypass attempts

The Hard Truth About Passkeys in 2026

Passkeys are the right direction, but enterprise adoption is still messy. The UX is great on consumer apps. Enterprise environments with shared workstations, locked-down browsers, or strict IT policies make passkey roaming credential sync complicated. Platform authenticators (Windows Hello, Touch ID) work great per-device but require cloud sync or re-enrollment on a new device.

Build for both. Offer passkeys as the default recommended path. Keep TOTP as a fallback. Remove SMS if you can. Communicate clearly that losing access to all registered authenticators and backup codes means account recovery — which should involve identity verification, not just "click a link in your email."

Authentication security is not a feature you ship once. It’s a posture you maintain. Review it when you add new endpoints, when you onboard third-party identity providers, when your session library gets a CVE, and every time someone on your team says "we’ll add MFA later."

Later is how you end up in a breach disclosure.

Leave a comment

👁 Views: 10,128 · Unique visitors: 14,506