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.
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:
| Algorithm | OWASP minimum parameters | When to use |
|---|---|---|
| Argon2id | m = 19 MiB, t = 2, p = 1 (or m = 12 MiB t = 3, m = 9 MiB t = 4) | Default choice for new systems |
| scrypt | N = 2^17, r = 8, p = 1 | When Argon2id is unavailable |
| bcrypt | Work factor 10 or more; input limited to 72 bytes | Legacy systems |
| PBKDF2 | 600,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 TrueThe 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 factor | Phishing resistant | Main weakness |
|---|---|---|
| SMS or voice code | No | SIM swap, interception, real-time relay |
| Email code or magic link | No | As strong as the mailbox; relayable |
| TOTP app (RFC 6238) | No | Code can be typed into a fake site and replayed within its window |
| Push approval | No, unless number matching | Push fatigue: users approve repeated prompts |
| WebAuthn security key or passkey | Yes | Recovery 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 TrueFor 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.
- Wrap now. In one batch job, replace each stored value with
argon2id(md5_hex)and mark the rowscheme='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. - 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. - Unwrap on login. After a successful wrapped verification, store
argon2id(password)withscheme='argon2id'. Within months most active users have migrated; the long tail remains protected by the wrapper. - Screen at the same time. Run the breached-password check during the unwrap step and require a change for passwords found in the corpus.
- 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
| Failure | Consequence | Fix |
|---|---|---|
| Fast hash (MD5, SHA-1, SHA-256) or no salt | Leaked database cracked in hours | Argon2id; wrap legacy hashes immediately |
| Different errors for unknown user | Account enumeration | Uniform responses and dummy-hash timing |
| Hard lockout after N failures | Attackers lock out real users | Progressive delays and challenges |
| Session issued before second factor | MFA bypass | Mark the session pre-MFA and grant nothing until it completes |
| Reusable or long-lived reset tokens | Takeover via old emails or logs | Single use, short expiry, stored hashed |
| SMS-only MFA for admins | SIM swap takes the admin console | Passkeys or security keys for privileged roles |
| bcrypt with long passphrases | Bytes past 72 silently ignored | Argon2id, 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
- Inventory every way to obtain a session: login, sign-up, recovery, social login, API keys, support tools.
- Confirm password hashes are Argon2id (or scrypt, bcrypt or PBKDF2 at OWASP parameters); wrap any legacy hashes this month.
- Adopt the NIST SP 800-63B-4 policy: length minimums, no composition rules, no forced rotation, breached-password screening.
- Make login, sign-up and reset responses uniform in content and timing.
- Add layered rate limits and alert on falling login success ratios.
- Offer passkeys to all users and require phishing-resistant factors for privileged accounts.
- Harden recovery: short-lived single-use tokens, session revocation and notifications on reset.
- Verify that no session grants access before every required factor has completed.