Authentication answers one question: is this request coming from the person or system it claims to come from? Every other control depends on the answer. Authorization rules, audit logs and rate limits are all keyed on an identity, and if an attacker can become that identity, those controls work perfectly on their behalf. Most account takeovers today do not break cryptography. They replay passwords leaked from other sites, phish one-time codes, abuse account recovery, or find a login endpoint that leaks which emails are registered.

This article explains authentication from first principles and then gets concrete about the parts you build yourself: how to store passwords, what password policy actually helps, how to harden the login endpoint against credential stuffing, which second factors resist phishing, and why account recovery is usually the weakest link. Parameters come from the OWASP Password Storage Cheat Sheet and NIST SP 800-63B-4, both checked on 2026-10-03. Related pages cover what happens after login: session management, JWT validation and passkeys.

Identification, authentication, sessions and factors

Four steps are often blurred together. Identification is the claim (an email address, a username, a client ID). Authentication is proof of the claim. Session issuance turns a successful proof into a short-lived credential, such as a cookie or token, so the user does not prove themselves on every request. Authorization decides what that identity may do. Bugs cluster at the seams: a login that authenticates correctly but issues a session before the second factor completes, or a recovery flow that issues a session with no proof at all.

Proofs come in three classic factors: something you know (a password, a PIN), something you have (a phone holding a TOTP secret, a security key, a passkey on a device), and something you are (a biometric, which in practice unlocks a key on a device rather than being sent to a server). Multi-factor authentication combines factors from different classes, so stealing one is not enough. The more useful modern distinction is phishing resistance: whether a proof can be relayed by a fake site. Passwords, SMS codes and TOTP codes can be typed into a convincing fake and replayed within seconds. WebAuthn credentials and passkeys cannot, because the browser binds the signature to the real site's origin.

Clientbrowser / appLogin endpointrate limit, bot checksidentifier + secretCredential storeArgon2id hashesverifySecond factorpasskey / TOTPstep 2Session issuercookie / tokenokokRecovery flowemail / supportRecovery issues the same session as login: it must be as strong.SignalsIP, device, velocityAuthentication ends when a session is issued; everything after that relies on the session, not the password.
A login proves an identity with one or more factors and ends by issuing a session; recovery issues the same session and needs the same strength.

Storing passwords

If you accept passwords at all, store them with a slow, salted, memory-hard password hashing function, never with a general-purpose hash such as SHA-256 and never with reversible encryption. A fast hash lets an attacker who steals the database test billions of guesses per second on GPUs; a memory-hard function makes each guess cost tens of megabytes and a measurable fraction of a second. The OWASP cheat sheet's current recommendations are:

AlgorithmOWASP minimum parametersWhen to use
Argon2idm = 19 MiB, t = 2, p = 1 (or m = 12 MiB t = 3, m = 9 MiB t = 4)Default choice for new systems
scryptN = 2^17, r = 8, p = 1When Argon2id is unavailable
bcryptWork factor 10 or more; input limited to 72 bytesLegacy systems
PBKDF2600,000 iterations with HMAC-SHA-256 (220,000 with SHA-512)When FIPS-140 compliance is required
# pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)  # memory in KiB

def register(email: str, password: str) -> None:
    db.users.insert(email=email, pw_hash=ph.hash(password))   # salt is generated and embedded

def check_password(user, password: str) -> bool:
    try:
        ph.verify(user.pw_hash, password)
    except VerifyMismatchError:
        return False
    if ph.check_needs_rehash(user.pw_hash):                    # parameters were raised since
        db.users.update(user.id, pw_hash=ph.hash(password))
    return True

The encoded hash carries the algorithm, parameters and salt, so you can raise parameters later and upgrade each user's hash transparently at their next login, as the code does. A pepper, a secret key mixed in via HMAC and stored outside the database (for example in a KMS or HSM), adds protection when only the database leaks; OWASP stresses that it must not live alongside the hashes. Tune the cost on your real hardware: verification should take tens to a few hundred milliseconds, and the login endpoint must be rate limited, because an expensive hash is also an expensive thing for an attacker to make you compute.

Password policy that helps

NIST SP 800-63B-4, finalised in August 2025, reverses most of the password rules users still meet. Passwords used as the only factor must be at least 15 characters; passwords used as part of multi-factor authentication may be as short as 8. Verifiers should accept at least 64 characters so passphrases fit. Composition rules (one uppercase, one digit, one symbol) must not be imposed, because they push users into predictable patterns such as Password1!. Periodic forced changes must not be required either; change a password when there is evidence of compromise.

What replaces those rules is a blocklist. Reject passwords that appear in breach corpora, dictionary words, and context-specific strings like the service name or the user's email. The Have I Been Pwned range API lets you do this without sending the password anywhere: hash it with SHA-1, send only the first five hex characters, and check the returned suffixes locally.

import hashlib, requests

def breached_count(password: str) -> int:
    h = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
    prefix, suffix = h[:5], h[5:]
    resp = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=2)
    resp.raise_for_status()
    for line in resp.text.splitlines():
        candidate, _, count = line.partition(":")
        if candidate == suffix:
            return int(count)
    return 0          # not seen in the corpus (not proof of strength)

Allow paste and password managers, show a strength meter if you like, and explain rejections in plain language. These measures help users pick passwords attackers have not already got.

Hardening the login endpoint

The login endpoint is attacked at scale. Credential stuffing replays email and password pairs leaked elsewhere, from thousands of IP addresses, at low rates per IP. Password spraying tries a few common passwords against many accounts. Enumeration uses different responses or timings to learn which emails are registered, which feeds both attacks and phishing.

  • Uniform responses. Return the same message and status for unknown user and wrong password, and do the same work: when the user does not exist, verify against a dummy hash so response time does not reveal it. Apply the same rule to sign-up and password reset pages.
  • Layered rate limits. Limit per account, per IP, per subnet or ASN, and globally on failure rate. Per-account limits alone let attackers lock real users out; prefer progressive delays and challenges to hard lockouts.
  • Breached-credential checks at login. If a correct password is in a breach corpus, force a reset after a second factor rather than letting the session continue.
  • Risk signals. New device, impossible travel and automation fingerprints should raise the required assurance (ask for the second factor) rather than silently block.
  • Notifications. Tell users about new-device logins and credential changes through a channel the attacker does not control.

Measure the failure ratio and the number of distinct accounts attempted per source. Credential stuffing looks like a login success rate falling from its normal level to a fraction of a percent while volume rises.

Second factors and phishing resistance

Second factorPhishing resistantMain weakness
SMS or voice codeNoSIM swap, interception, real-time relay
Email code or magic linkNoAs strong as the mailbox; relayable
TOTP app (RFC 6238)NoCode can be typed into a fake site and replayed within its window
Push approvalNo, unless number matchingPush fatigue: users approve repeated prompts
WebAuthn security key or passkeyYesRecovery and device loss need planning

TOTP is still a large improvement over passwords alone. Verify it with a small window, store the seed encrypted, and reject a code that has already been used.

import pyotp

def verify_totp(user, code: str) -> bool:
    totp = pyotp.TOTP(decrypt(user.totp_seed))
    if not totp.verify(code, valid_window=1):          # accept one step either side for clock skew
        return False
    if cache.get(f"totp-used:{user.id}:{code}"):       # block replay inside the window
        return False
    cache.set(f"totp-used:{user.id}:{code}", 1, ttl=90)
    return True

For administrators and other high-value accounts, require phishing-resistant factors. The WebAuthn article explains the challenge-response protocol and origin binding that make that true.

Account recovery

A recovery flow issues the same session as a login, so the account is only as strong as the weakest way to get back in. Common failures include security questions whose answers are on social media, reset links that never expire or can be reused, reset tokens that leak through the Referer header, and support agents who reset MFA for a confident caller.

  • Reset tokens: at least 128 bits from a cryptographic random generator, stored hashed, single use, valid for minutes, and invalidated when the password changes.
  • After a reset, revoke existing sessions and refresh tokens, and notify the user on every registered channel.
  • Do not let a reset by email alone remove a second factor; require the factor, a recovery code issued at enrolment, or a slower, audited identity check.
  • Encourage users to register two passkeys or security keys so losing one device is not a recovery event.

Worked example: migrating legacy MD5 hashes

A shop has 2 million users whose passwords are stored as unsalted MD5 from a decade ago. Waiting for every user to log in would leave inactive accounts exposed for years, so migrate in two steps.

  1. Wrap now. In one batch job, replace each stored value with argon2id(md5_hex) and mark the row scheme='wrapped-md5'. At OWASP parameters each hash costs tens of milliseconds, so 2 million rows take on the order of a day on one core and an hour or two on a modest worker pool. The MD5 values no longer exist at rest.
  2. Verify both schemes. At login, for wrapped rows compute md5(password) and verify it against the Argon2id hash; for native rows verify the password directly.
  3. Unwrap on login. After a successful wrapped verification, store argon2id(password) with scheme='argon2id'. Within months most active users have migrated; the long tail remains protected by the wrapper.
  4. Screen at the same time. Run the breached-password check during the unwrap step and require a change for passwords found in the corpus.
  5. Retire. After a fixed period, force a reset for the remaining wrapped accounts at their next login and delete the MD5 code path.

Failure modes

FailureConsequenceFix
Fast hash (MD5, SHA-1, SHA-256) or no saltLeaked database cracked in hoursArgon2id; wrap legacy hashes immediately
Different errors for unknown userAccount enumerationUniform responses and dummy-hash timing
Hard lockout after N failuresAttackers lock out real usersProgressive delays and challenges
Session issued before second factorMFA bypassMark the session pre-MFA and grant nothing until it completes
Reusable or long-lived reset tokensTakeover via old emails or logsSingle use, short expiry, stored hashed
SMS-only MFA for adminsSIM swap takes the admin consolePasskeys or security keys for privileged roles
bcrypt with long passphrasesBytes past 72 silently ignoredArgon2id, or document and enforce the limit

Trade-offs: build, buy and friction

Building authentication yourself gives full control and no per-user fees, and makes you responsible for every row above. An identity provider using OAuth and OpenID Connect, or SAML for enterprise customers, centralises that work, adds single sign-on and usually offers passkeys and risk scoring, at the price of a dependency on its availability and pricing. Stronger factors cost some friction and support load; the usual balance is passkeys offered to everyone, required for staff and administrators, with TOTP as a fallback and SMS only where nothing else is possible.

What to do next

  1. Inventory every way to obtain a session: login, sign-up, recovery, social login, API keys, support tools.
  2. Confirm password hashes are Argon2id (or scrypt, bcrypt or PBKDF2 at OWASP parameters); wrap any legacy hashes this month.
  3. Adopt the NIST SP 800-63B-4 policy: length minimums, no composition rules, no forced rotation, breached-password screening.
  4. Make login, sign-up and reset responses uniform in content and timing.
  5. Add layered rate limits and alert on falling login success ratios.
  6. Offer passkeys to all users and require phishing-resistant factors for privileged accounts.
  7. Harden recovery: short-lived single-use tokens, session revocation and notifications on reset.
  8. Verify that no session grants access before every required factor has completed.
Key takeaway: Authentication proves a claimed identity and ends by issuing a session, so every path that issues a session, including recovery, must be equally strong. Store passwords with Argon2id at OWASP parameters, follow NIST SP 800-63B-4 on length and breached-password screening instead of composition and rotation rules, make login responses uniform and rate limited in layers, prefer phishing-resistant passkeys for second factors, and harden recovery with short-lived single-use tokens.