Chapter 04 · Part I · Foundations

Passwords, MFA & passkeys

Every login is a computer trying to answer one question with very little evidence: is this really the person who owns the account? This chapter is about the evidence it can ask for, how each kind fails, and the arithmetic that decides how long a stolen password database stays safe.

15characters: NIST's minimum length for a password used on its own (SP 800-63B-4, 2025), with no rules about symbols or digits
1,000,000possible six-digit codes, and a new one every 30 seconds by default (RFC 6238)
19 MiBof memory per guess: OWASP's minimum Argon2id setting (with 2 passes, 1 lane) for storing a password

Common mix-up: two-step login is not the same as phishing-resistant login. A six-digit code stops a stranger who only has your password, but a fake site can simply ask you for the code and use it within seconds. Only methods that check the website's name cryptographically, such as passkeys, close that door.

Before you start: this chapter assumes you know how a client opens a connection to a server on a port, from TCP, UDP & ports on networks.jelia.nyc, and it leans on the hash functions from Chapter 2.

Three different questions

Who are you, prove it, and what may you do

Three words get blurred together, and the blur causes real bugs. Identification is a claim: you type a username or an email address and say "I am this account." Nothing has been proven yet; anyone can type anyone's name. Authentication is the proof: the system asks for evidence that only the real owner should be able to produce, and checks it. Authorization comes after, and is a different decision: now that we believe you are this account, what is this account allowed to do? A system that authenticates perfectly can still leak everything if its authorization check is missing, and a perfect permissions model is worthless if anyone can log in as anyone.

The evidence comes in three classic kinds, called factors. Something you know: a password, a PIN. Something you have: a phone with an app on it, a hardware security key, a smart card. Something you are: a fingerprint or a face. Multi-factor authentication (MFA) means asking for two or more of different kinds, so a thief needs to succeed at two unrelated things. A password plus a security question is not MFA; both are things you know, and both can be phished or guessed the same way.

One detail is easy to miss. On a modern phone or laptop, the fingerprint or face usually never leaves the device. It unlocks a secret stored in the device's secure hardware, and it is that secret the website checks. So "something you are" is mostly a local key to "something you have." The server never learns your fingerprint, which is good, because you cannot change your fingerprint after a breach.

FactorExamplesTypical failureWho mostly has to defend it
KnowPassword, PIN, passphraseGuessed, reused on another site, typed into a fake page, stolen from a badly stored databaseThe user (choosing well) and the service (storing it well)
HaveAuthenticator app, security key, phone receiving a text, smart cardCodes relayed through a fake site; phone number moved to a criminal's SIM; device lostThe service (choosing a method that resists relay)
AreFingerprint, face, used to unlock a deviceRarely attacked directly at scale; mostly matters as the lock on a device keyThe device maker
Guessing is arithmetic

How many guesses is a password worth

If a password is chosen truly at random, its strength is a counting problem. A password of length L built from an alphabet of N symbols is one of NL possibilities. Security people quote that as bits of entropy, the base-2 logarithm of the number of possibilities, because each extra bit doubles the attacker's work.

bits = log₂(NL) = L × log₂(N)
Each extra character adds log₂(N) bits. Lowercase letters (N = 26) give about 4.7 bits per character; the 95 printable keyboard characters give about 6.6. On average an attacker finds a random password after trying half the space: 2bits − 1 guesses.

Worked example: eight random lowercase letters give 8 × 4.70 = 37.6 bits, about 209 billion possibilities. Eight random characters from the whole keyboard give 8 × 6.57 = 52.6 bits, about 6.6 quadrillion. Twelve random keyboard characters give 78.8 bits. Length wins: going from 8 to 12 characters of the full keyboard multiplies the space by 954, about 81 million, far more than adding symbols to a short password ever could.

The formula has a large catch. It is only true when a machine picks the password. People don't pick uniformly. Asked for "8 characters with an uppercase letter, a digit and a symbol," most people produce a word with a capital at the front and a digit and a symbol at the end: Summer2024!. The formula credits that with 72 bits. A guesser that tries common words first, capitalizes the first letter, appends recent years and a handful of symbols, and swaps a for @, needs on the order of a few billion guesses, around 30 bits. The calculator below shows both numbers for anything you type.

That gap is why passphrases work. Pick words at random, by dice or by a generator, from a list of 7,776 words (the size of the classic five-dice list) and each word is worth log₂(7776) ≈ 12.9 bits. Four words give 51.7 bits; six give 77.5 bits, comparable to twelve random keyboard characters and far easier to type and remember. The randomness has to come from the dice, not from your head: a famous quotation or song lyric is a few guesses away.

Whether 40 bits is "enough" depends entirely on where the guessing happens, and that is the most important idea in this chapter.

Online guessing means the attacker tries passwords against the real login page. Every guess is a network round trip the service can see, slow down and count. NIST's current guidance requires a verifier to cap consecutive failed attempts on one account at no more than 100 before disabling that authenticator, and services commonly add growing delays, CAPTCHAs or risk checks before that. At 100 guesses, a password from a space of even a million possibilities has a 1-in-10,000 chance of falling, which is why NIST can accept shorter passwords when they are one factor among several.

Offline guessing is the dangerous case. If attackers steal the database of stored password hashes, nobody is counting any more. They can run guesses on their own hardware, as fast as it will go, for as long as they like, and they only need to compare each guess against the stolen value. Everything about how passwords should be stored is about making this case slow.

Go deeper: why "bits" and not "number of combinations"

Bits turn multiplication into addition. Two independent random choices of 20 bits and 30 bits combine into 50 bits, rather than "a million times a billion." That makes it easy to see that a random four-word passphrase (51.7) beats an eight-character password from the full keyboard (52.6) only narrowly, while a fifth word (64.6) beats it comfortably.

It also makes the attacker's side easy to reason about. A rig that tries 240 guesses (about 1.1 trillion) per second empties a 50-bit space in 210 = 1,024 seconds and a 70-bit space in 230 seconds, about 34 years. Every ten bits is roughly a factor of a thousand.

For human-chosen passwords the honest measure is not entropy but guess rank: how far down an attacker's ordered list of likely passwords yours appears. That is why NIST asks services to reject passwords found on lists of common and breached passwords instead of scoring them with a formula.

The database will leak one day

How a password must be stored

A service has to check your password without being able to give it back. The design question is what to keep in the database so that, if the whole table is copied, the copy is as useless as possible. There are four wrong answers and one right one.

Plaintext is the obvious wrong answer: a leak is an instant list of working passwords, and because people reuse them, of working passwords for other sites too. Reversible encryption is the subtle wrong answer: the decryption key has to live somewhere the login code can reach it, so anyone who steals the database from the same servers can usually steal the key too. A password store should be one-way; nobody, including the service, should be able to recover the original.

So we use a one-way hash, from Chapter 2. At sign-up the service stores hash(password); at login it hashes what you typed and compares. But a fast hash such as MD5, SHA-1 or a single round of SHA-256 is the third wrong answer. Those functions were designed to be quick, so a single graphics card can compute billions of them a second, and the attacker's offline guessing runs at that speed.

The fourth wrong answer is hashing without a salt. If everybody's password goes through the same function with nothing else mixed in, then two users with the same password have the same stored value, which leaks who shares a password. Worse, an attacker can compute hashes of billions of likely passwords once, store them in a big lookup table, and reuse the table against every leaked database in the world. A salt is a random value, unique to each user, stored next to the hash and mixed into it: hash(salt + password). Identical passwords now look different, precomputed tables become useless, and an attacker who wants every account must do the guessing work separately for each one. NIST requires the salt to be at least 32 bits; libraries typically use 128.

The right answer is a dedicated password hashing function: salted, and deliberately slow, with a work factor you can turn up as hardware gets faster. The best ones are also memory-hard: every guess has to fill and re-read a large block of memory. Graphics cards and custom chips get their speed from thousands of small cores running in parallel; each core has very little memory of its own, so demanding tens of megabytes per guess takes away most of that advantage. Argon2, the winner of the 2015 Password Hashing Competition and specified in RFC 9106, does exactly this. Its Argon2id variant mixes two memory-access strategies to resist both graphics-card guessing and side-channel attacks, and it is what OWASP recommends first.

FunctionOWASP minimum setting (Password Storage Cheat Sheet)Notes
Argon2idm = 19456 (19 MiB), t = 2, p = 1First choice. Equivalent alternatives listed by OWASP: 46 MiB with t = 1; 12 MiB with t = 3; 9 MiB with t = 4; 7 MiB with t = 5 (all p = 1)
scryptN = 217, r = 8 (1024-byte blocks), p = 1Use when Argon2id isn't available. Also memory-hard (RFC 7914)
bcryptwork factor 10 or moreFor legacy systems. Only the first 72 bytes of input count, so length beyond that is silently ignored
PBKDF2600,000 iterations of HMAC-SHA-256When FIPS-140 compliance is required. 220,000 with HMAC-SHA-512. Not memory-hard (RFC 8018)

What gets stored is one self-describing string that carries the algorithm, its settings, the salt and the result, so the service can raise the settings later and re-hash each user at their next successful login. An illustrative example (made-up salt and hash):

stored = md5(password)        # fast, unsalted: a leak can be guessed at billions per second
stored = sha256(password)     # still fast; still identical for identical passwords
stored = encrypt(key, password)  # reversible: whoever finds the key reads every password
# Python with the argon2-cffi library: salt is generated for you and stored in the string
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
stored = ph.hash(password)          # "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$...(hash)..."
ph.verify(stored, attempt)          # raises an exception on a wrong password
if ph.check_needs_rehash(stored):   # settings were raised since this user last logged in
    stored = ph.hash(attempt)
Go deeper: peppers, the bcrypt 72-byte limit, and tuning

A pepper is a second secret mixed into every hash but stored somewhere other than the database, such as a hardware security module or a separate secrets store. If only the database leaks, the attacker can't even start guessing. OWASP lists it as defense in depth, not a replacement for a proper password hash: if the pepper leaks with the database, you are back to relying on the slow hash.

bcrypt reads at most 72 bytes of password. Two long passphrases that share their first 72 bytes are the same password to bcrypt. Some systems pre-hash long inputs before bcrypt; OWASP warns that doing this carelessly can create other problems, which is one more reason to prefer Argon2id for new systems.

Tuning is a budget decision. Every login now costs the service the same work it costs the attacker per guess. The aim is the highest setting your login servers can afford at peak load, typically somewhere around a few hundred milliseconds or less of compute per login. OWASP's numbers are floors, not targets. RFC 9106 gives the meaning of each Argon2 parameter: m is total memory in KiB, t the number of passes over it, p the number of lanes the memory is divided into.

Instrument 1

The guess-cost calculator

Pick a password policy or type a sample, then choose how the service stored it and how much hardware the attacker has. The calculator works out the size of the space, the expected number of guesses and how long an offline attacker would need after a database leak, for every storage method at once. What you type is processed only in this page: it is never sent anywhere and never saved. Use a made-up example, not a real password.

Formula size–
Pattern-aware size–
Guesses per second–
Offline: typical time–
Online, 100-try limit–

Assumed speeds are illustrative orders of magnitude for one current high-end graphics card, not benchmarks: fast hash 10 billion guesses/s; PBKDF2-SHA-256 at 600,000 iterations about 8,000/s; bcrypt 100,000/s at cost 5, halving with each cost step; Argon2id about 1,000/s at 19 MiB and t = 2, falling in proportion to memory × passes. The "pattern-aware" figure is a rough heuristic for a guesser that tries common passwords, dictionary words, years, capitals and letter-for-symbol swaps first.

One password, many doors

Credential stuffing

The most common attack on passwords today barely involves guessing. When one site leaks usernames and passwords, attackers take the list and try each pair, automatically, against other sites: email providers, shops, banks. Because so many people reuse a password across sites, a fraction of those attempts succeed. This is credential stuffing, and MITRE ATT&CK catalogs it as a sub-technique of brute force (T1110.004). From the victim site's point of view nothing was breached at all; the attacker simply logged in with correct credentials.

Stuffing defeats the defenses aimed at guessing. Each account sees one or two attempts, nowhere near a lockout threshold, and the attempts are usually spread across many IP addresses. So the defenses look different:

  • Screen passwords against breach lists when they are set or changed. NIST requires services to compare a new password against a blocklist of common, expected or compromised values, and to make the user choose another if it matches.
  • Add a second factor. A correct password alone then gets the attacker nowhere, and an unexpected prompt warns the owner.
  • Watch the whole login stream, not one account. Many accounts failing from the same address ranges, unusual devices, impossible travel and sudden spikes in failure rate are the signal. OWASP's Credential Stuffing Prevention Cheat Sheet collects these.
  • Tell users when their password shows up in a breach and force a change then; NIST requires a change when there is evidence of compromise, while forbidding routine forced changes.

Checking a password against a list of billions of breached ones raises its own privacy problem: you do not want to send the password, or even its full hash, to a third party. The well-known answer is a k-anonymity range query, used by the Pwned Passwords service. The client computes the SHA-1 hash of the candidate password locally and sends only the first five hexadecimal characters. The service returns every breached hash suffix that starts with that prefix, typically hundreds of them, with counts, and the client checks locally whether its own suffix is in the list. The service learns only which of 165 = 1,048,576 buckets you asked about, a bucket shared by many unrelated passwords.

SHA-1, computed here
Would be sent (solid underline)–
Kept on your device (dotted)–
Buckets the prefix picks from1,048,576

This box computes the hash with a SHA-1 written in this page's own JavaScript and sends nothing; it only shows which part a real range query would reveal. SHA-1 is used here because that is what the range query is keyed on, not because it is fit for storing passwords.

What the rulebook says now

NIST changed its mind

For years, password policies meant: at least eight characters, an uppercase letter, a digit, a symbol, and a new password every 90 days. Research and breach data showed those rules mostly produced Password1!, then Password2!. NIST's Digital Identity Guidelines, SP 800-63B, dropped them, and revision 4 (finalized in 2025) turned most of the advice into firm requirements. The words below are the standard's own: SHALL is a requirement, SHOULD a recommendation.

TopicSP 800-63B-4 saysWhy
LengthSHALL require at least 15 characters for a password used alone; at least 8 when it is only one part of MFA. SHOULD permit at least 64Length is the primary factor in strength, and long passphrases need room
CompositionSHALL NOT impose other composition rules, such as requiring mixtures of character typesPeople satisfy them with predictable patterns
ExpirySHALL NOT require periodic changes; SHALL force a change on evidence of compromiseForced rotation produces small, guessable edits
BlocklistSHALL compare new passwords against common, expected or compromised values, checking the whole passwordStops the guesses attackers try first
CharactersSHOULD accept all printing ASCII and space, and Unicode; each code point counts as one characterLets people use passphrases in their own language
Hints, KBASHALL NOT allow stored hints or prompt for knowledge-based questions ("first pet")Answers are often public or guessable
Rate limitSHALL limit consecutive failed attempts on one account to no more than 100Makes online guessing hopeless for any non-trivial password
StorageSHALL salt and hash with a suitable password hashing scheme; salt at least 32 bitsMakes offline guessing expensive
PhishingManually entered outputs (OTP and out-of-band codes) SHALL NOT be considered phishing-resistantA fake site can relay them
Go deeper: assurance levels, and where text messages stand

SP 800-63B grades logins into three Authentication Assurance Levels. AAL1 permits a single factor. AAL2 requires two factors, and revision 4 says a phishing-resistant option must be offered. AAL3 requires a phishing-resistant cryptographic authenticator whose key cannot be exported, such as a hardware security key.

Codes sent by text message or voice call travel over the public telephone network. The standard treats that as a restricted method: allowed, but the service has to offer an alternative and consider the risks, which include a criminal persuading a mobile carrier to move your number to their SIM card, and weaknesses in the signalling network between carriers.

Something you have

How a six-digit code is made

The code in an authenticator app is a time-based one-time password (TOTP), defined in RFC 6238 on top of the older counter-based HOTP of RFC 4226. When you scan the QR code at setup, the service and your app now share a random secret. Nothing passes between them after that. Each side independently takes the current time, divides it into 30-second steps, and mixes the step number with the secret.

T = ⌊ unix_time ÷ 30 ⌋
code = Truncate( HMAC-SHA-1(secret, T) ) mod 10⁶
T is the count of 30-second steps since 1 January 1970 UTC. The HMAC turns secret and step into 20 bytes that look random. "Truncate" uses the last 4 bits of the HMAC to pick a starting position, reads 4 bytes from there, clears the top bit, and the last six decimal digits of that number are the code.

Because both sides compute the same thing, the server can check your code without any message from your phone. Clocks drift and people type slowly, so RFC 6238 recommends that a server accept at most one previous step for network delay, and it must refuse a code that has already been used once. A guesser online faces one chance in a million per try, and the rate limit stops them long before that matters.

Its weakness is that the code is a short string a human reads and types. Nothing in those six digits says which website they were meant for. If you are on a convincing copy of your bank's login page, you will type the code there, and the copy can forward it to the real bank within the same 30-second window. The second panel below plays that out, and then plays the same attempt with a passkey.

Instrument 2

A working TOTP clock, and the relay

This is a real RFC 6238 generator running on your clock, with a published demo secret: the ASCII text 12345678901234567890 that the RFC itself uses for its test vectors (Base32 GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ). Never use a published secret for a real account. Every step is shown: the time step T, all 20 bytes of the HMAC, the offset and the four bytes it picks, and the six digits.

Current code
------

The RFC's first test vector: at unix time 59 the 8-digit SHA-1 code is 94287082.

HMAC-SHA-1(secret, T): 20 bytes. Dashed = byte 19, whose last hex digit is the offset. Solid = the 4 bytes read
The relay: a look-alike site in the middle

The relay timing is computed with the same rule a careful server uses: accept the code for the current 30-second step or the one before it, and nothing older. The look-alike name examp1e.com has the digit 1 in place of the letter l.

Tap to approve

Push prompts and fatigue

Push MFA replaces typing a code with a notification: "Are you trying to sign in? Approve / Deny." It is pleasant to use and avoids text messages, but it moves the decision onto a tired human. An attacker who already has the password can trigger prompt after prompt, often late at night, until the owner taps Approve to make it stop, or is talked into it by a fake "help desk" call. This is called MFA fatigue or push bombing, and MITRE ATT&CK lists it as Multi-Factor Authentication Request Generation (T1621).

The defenses are well understood:

  • Number matching. The login screen shows a two- or three-digit number and the user must type it into the app. A user who did not start a login has no number to type, so a blind tap no longer approves anything. CISA lists push with number matching alongside app-based codes, and push without it below them.
  • Show context in the prompt: the application, the approximate location and the time, so a surprise is visible.
  • Limit and alert. Cap the number of prompts per period, and treat a burst of denied or ignored prompts as a security event worth a look, because it means somebody has the password.
  • Teach one rule: if you didn't just try to sign in, deny and report. A real help desk never needs you to approve a prompt.

Number matching stops blind approval but still isn't phishing-resistant: on a look-alike site, the attacker can show you the real number, and you will type it in good faith. Only one family of methods breaks that relay, and it does it with cryptography rather than vigilance.

Something you have, done properly

Passkeys and origin binding

WebAuthn, a W3C standard developed with the FIDO Alliance as part of FIDO2, replaces shared secrets with public-key cryptography. At registration, your authenticator, which can be a phone, a laptop's secure chip or a hardware key, creates a brand-new key pair just for this site. The private key never leaves the authenticator; the site stores only the public key. A passkey is the consumer name for such a credential when it can replace the password entirely: the device unlocks it with your fingerprint, face or PIN, and that local check together with possession of the device makes two factors.

At login the site sends a fresh random challenge. The browser packages the challenge together with the origin it is actually talking to, the scheme and hostname in the address bar, and the authenticator signs that package with the private key it holds for this site. The site checks the signature with its stored public key, checks that the challenge is the one it just issued, and checks that the origin is its own.

Three things make this phishing-resistant, and none of them depends on the user noticing anything:

  • Per-site keys. Each credential is scoped to a relying party ID, a domain such as example.com. The browser will only use it for pages on that domain. On examp1e.com there is simply no credential to offer.
  • The browser, not the page, reports the origin. Even if a look-alike forwards the real site's challenge, the signed package says https://examp1e.com, and the real site rejects it.
  • Nothing reusable crosses the wire. The signature covers one challenge, so a captured response is useless later, and a breached server holds only public keys, which are worthless to an attacker.
Passkey sign-in: the real site succeeds, the look-alike gets nothing Top row: the browser on example.com sends a challenge and the origin example.com to the authenticator, which finds a key for example.com and signs; the site verifies and accepts. Bottom row: the browser on examp1e.com asks for a credential for examp1e.com; the authenticator has none, and even a forwarded challenge would be signed with origin examp1e.com, which the real site rejects. ✓ The real site: example.com Browserorigin:example.com Authenticatorkey forexample.com ✓ example.comsignature + originverified: sign in ✗ The look-alike: examp1e.com Browserorigin:examp1e.com Authenticatorkey for examp1e.com?none: nothing signed example.comforwarded reply saysexamp1e.com: reject Solid = legitimate path · dashed = look-alike path
The origin is filled in by the browser from the address it is actually connected to, which is the same fact Chapter 3's certificates vouch for. A page cannot set it.

Synced or device-bound. A passkey can live in one piece of hardware, such as a USB security key, from which it cannot be copied. Or it can be synced: the platform's password manager copies it, end-to-end encrypted, to your other devices, so losing a phone does not lose the account. NIST's revision 4 accepts syncable authenticators at AAL2 with conditions on how the sync service protects them; AAL3 requires keys that can't be exported. Synced passkeys trade a little control for a lot less lockout, and for most people that is the right trade.

Recovery is the weak link. Every strong login has a back door labelled "I lost my device." If that door is an email link, a text-message code or a support agent who can be talked round, attackers will skip the passkey and go through the door. A sound design lets users register more than one authenticator, offers one-time recovery codes generated at setup, notifies the account on every recovery, and treats a recovery as a high-risk event with a delay or extra checks. OWASP's Forgot Password Cheat Sheet covers the mechanics.

Go deeper: what the server actually checks

The authenticator returns three things: clientDataJSON (built by the browser: the type of ceremony, the challenge and the origin), authenticatorData (built by the authenticator: a SHA-256 hash of the relying party ID, flags saying whether the user was present and verified, and a signature counter), and a signature over both. The server verifies, among other steps in the WebAuthn specification: that the challenge matches the one it issued for this session; that the origin is one it expects; that the RP ID hash equals the hash of its own RP ID; that the user-verified flag is set if it requires it; and that the signature checks out against the stored public key.

The RP ID must be the page's own domain or a parent of it that isn't a public suffix. So login.example.com may use example.com, but examp1e.com cannot claim example.com; the browser refuses before the authenticator is even asked.

Ranking the options

The phishing-resistance ladder

Any second factor is far better than none: it defeats credential stuffing and password guessing outright. Against phishing, the methods separate sharply. CISA's guidance ranks them, from weakest to strongest, like this:

RungMethodStops stuffingStops a real-time relayMain residual risk
1Text or voice codeYesNoPhishing, SIM swap, telephone-network interception. CISA: last resort
2Push without number matchingYesNoPush bombing, user error
3Authenticator-app or token code (TOTP); push with number matchingYesNoPhishing relay within the time window
4FIDO2 / WebAuthn passkey or security key; PKI smart cardYesYesAccount recovery, a compromised device

Once a login succeeds, the server hands the browser a session token so you don't prove yourself on every click, and that token becomes the new thing worth stealing. Cookies, session lifetime and cross-site request forgery are the subject of Chapter 15. How a central identity provider makes the decision for many applications at once is in Chapter 11, and how the phishing email reaches you in the first place is in Chapter 10.

Where this comes from

Sources