A History of the Password Login
The password is the worst idea in security that absolutely refuses to die. Every few years someone announces its death, and every year there are more of them. So instead of killing it, we’ve spent forty years making it less catastrophic to store — each step a reaction to how the previous step got broken. It’s one of the cleaner examples of security evolving by autopsy.
I want to walk that history, because it ends somewhere genuinely clever: a protocol where the password never reaches the server at all. I built a demo of that endpoint, OPAQUE, and wrote it up in a companion post. This is the road that leads there.
Stage 0: the plaintext column
In the beginning there was SELECT * FROM users WHERE pass = 'hunter2'.
The password sat in a database column in the clear. The threat model was, charitably, “nobody will ever read this table.” Anyone with database access — a DBA, an attacker with a SQL injection, a backup tape that fell off a truck — got every password for every user instantly. And since people reuse passwords, one breach was a skeleton key to those users’ other accounts.
The fix is the one durable idea in this whole story: don’t store the password, store something derived from it that you can check but not reverse.
Stage 1: one-way hashing
So we hashed. Store H(password), and at login hash the submitted password and
compare. H is one-way, so a stolen database is no longer a list of passwords —
it’s a list of hashes.
stored: md5("hunter2") = 0x7f8e... (not the password, just its fingerprint)
login: md5(submitted) == stored ?Better. But two problems showed up fast.
First, the early hashes were fast general-purpose hashes — MD5, SHA-1. Fast is exactly wrong here: it lets an attacker test billions of guesses a second against a stolen hash. A hash being fast is a feature for checksums and a vulnerability for passwords.
Second, and worse, the hash is deterministic and identical for everyone.
md5("hunter2") is the same bytes in your database as in mine. Which sets up the
next attack.
Stage 2: salt, because of rainbow tables
If everyone’s hash of "password123" is the same value, an attacker doesn’t even
need to crack your database. They precompute the hashes of every likely password
once, into a giant lookup table, and then a stolen hash is just a reverse
dictionary lookup. The space-efficient version of that precomputed table is a
rainbow table, and for unsalted hashes of common passwords it turned cracking
into an O(1) lookup.
The defense is salt: a unique random value per user, stored alongside the hash and mixed into it.
stored: salt, H(salt || password)Now everyone’s hash of "password123" is different, because everyone’s salt is
different. A precomputed table is worthless — the attacker would need a separate
table per salt, which defeats the entire point of precomputing. Salt doesn’t
make any single password harder to guess; it makes the attacker pay for each user
individually instead of cracking the whole database in one amortized sweep. It
kills pre-computation.
Salt is non-negotiable to this day, and notably it’s not a secret — it can sit right next to the hash. Its only job is to be unique.
Stage 3: make it slow on purpose (compute-hard)
Salt stops precomputation, but a stolen salted hash is still attackable one user at a time — and against a fast hash, “one user at a time” still means billions of guesses a second on commodity hardware. The answer was to make the hash deliberately, tunably slow.
This is key stretching. Run the hash thousands or millions of times, or use a function designed to be costly:
- PBKDF2 — iterate an HMAC a configurable number of times. Want it slower as CPUs get faster? Raise the iteration count.
- bcrypt — built around a deliberately expensive key schedule, with a tunable cost factor baked into the output.
The knob is the whole idea: tune the cost so a legitimate login takes maybe a hundred milliseconds — unnoticeable to a user doing it once — while an attacker trying to grind a stolen database does that work per guess, per user, and drowns. You’re not making the password un-crackable; you’re making it expensive enough that weak-but-not-terrible passwords survive a breach long enough to be rotated.
Stage 4: GPUs don’t care about your CPU cost (memory-hard)
Then the hardware changed. Attackers stopped grinding hashes on CPUs and moved to GPUs, FPGAs, and ASICs — thousands of parallel cores, each cheerfully computing bcrypt or PBKDF2. A compute cost calibrated for one CPU core is a rounding error across ten thousand GPU lanes. The cost knob was being out-scaled by parallelism.
The counter was to make the function memory-hard: force it to use a large amount of RAM, not just CPU time. Memory is the one resource a GPU or ASIC can’t cheaply multiply by ten thousand — fast memory is expensive and physically bulky, so a function that needs 64 MiB per guess makes massive parallelism economically painful.
- scrypt — the first widely used memory-hard KDF.
- Argon2 — winner of the 2015 Password Hashing Competition, now the default recommendation, with separate knobs for memory, time, and parallelism.
This is roughly where well-built password storage sits today: a per-user salt fed into a memory-hard, tunable KDF like Argon2id. It’s genuinely good. The OPAQUE demo uses Argon2id for exactly this reason.
And yet — every single stage so far, from the plaintext column to Argon2id, shares one unexamined assumption.
The assumption nobody questioned: the server sees the password
Look back at all four stages. In every one of them, login means sending the password to the server, which then hashes it. TLS protects it in transit, sure. But at the moment of login the server holds your actual plaintext password in memory, however briefly, before it runs the KDF.
That sliver of exposure is a real attack surface:
- It can be logged — a misconfigured request logger, a debug
print, an APM tool capturing request bodies. Plaintext passwords leak into logs constantly. - It can be phished — the whole game of phishing is convincing you to send your password to the wrong server. If login means “send your password,” there’s always something to send to the wrong place.
- It can be intercepted — anywhere TLS is terminated (a load balancer, a proxy, a compromised CDN), something sees the cleartext.
- A compromised server harvests passwords live, as users log in, even if the stored database is perfectly hashed.
Hashing protects the password at rest. None of it protects the password at the moment of use. To close that gap you need a fundamentally different shape of protocol — one where the password is used to authenticate without ever being transmitted. That’s the family of password-authenticated key exchange (PAKE).
Stage 5: SRP — the password stops crossing the wire
The first PAKE to see serious deployment was SRP, the Secure Remote Password protocol (the practical version being SRP-6a). It was a real conceptual leap: the client proves it knows the password through a challenge-response built on modular exponentiation, and the password itself never goes over the network.
The server stores a verifier, v = g^x mod N where x is derived from the
salt and password, rather than a hash it compares against. Through some clever
algebra, both sides end up agreeing on a shared session key only if the client
genuinely knew the password — and the server never learns x, never sees the
password, and an eavesdropper gets nothing useful. SRP is an augmented PAKE:
even the server’s stored verifier can’t be replayed to impersonate the user.
For its time this was excellent, and SRP still runs in plenty of places (it’s how Apple does a lot of its authentication). But it accumulated rough edges:
- The stored verifier is still offline-attackable. Steal the database and you
can mount an offline dictionary attack against
vto recover passwords. Better than a fast hash, but it’s not magic. - It’s married to specific math. SRP is defined over large integer groups with carefully chosen parameters; it doesn’t translate cleanly to modern elliptic curves, and it isn’t modular — you can’t swap in your preferred primitives.
- The security proofs lagged. SRP was engineered before the formal aPAKE models existed, so for years it lacked the kind of clean, composable proof that modern protocols are designed around. It also doesn’t compose neatly with TLS channel binding.
- Pre-computation creeps back in. Depending on how the salt is handled at login, an attacker who hasn’t even breached you yet can do some precomputation — the very thing salt was introduced to kill.
SRP proved the thesis: you can log in without sending your password. The open question it left was whether you could do that with a clean, modular, provably-secure construction that resisted pre-computation and rode on modern curves.
Stage 6: OPAQUE — the aPAKE we were missing
That’s the gap OPAQUE fills. It’s a strong aPAKE, standardized as RFC 9807, and it’s built by composing two well-understood pieces rather than one bespoke bundle of algebra:
- An oblivious pseudorandom function (OPRF), which lets the client compute a salted, stretched secret from its password using a key only the server holds — without the server seeing the password and without the client seeing the key.
- A standard authenticated key exchange (AKE) — a 3DH handshake — for mutual authentication and a forward-secret session key.
That decomposition is the point. Each piece is independently understood and provable, and the whole composes cleanly. From it OPAQUE gets the properties SRP reached for and the ones it missed:
- The password never crosses the wire — the win that the entire hashing lineage never achieved. There is nothing to phish from the network, nothing to intercept at a TLS terminator, nothing to leak into a log, no plaintext in server memory at the moment of use.
- Pre-computation resistance — the OPRF key is the server’s secret, so an attacker can’t build a dictionary before breaching the server. (Once they breach it, the stored record is still offline-attackable — but that work is now gated behind a memory-hard KDF and can’t be front-run.)
- Account enumeration resistance — the server can answer an unknown account with a well-formed fake response, so probing for valid accounts gets you nothing.
- Forward secrecy and mutual authentication, courtesy of the AKE: the client verifies it’s talking to the real server, not just the reverse.
- Modern, modular primitives — pick your elliptic curve, your hash, your KDF, your memory-hard stretching function. No marriage to one integer group.
You can read it as the logical terminus of everything above. Plaintext taught us to derive, not store. Hashing gave us one-wayness. Salt killed precomputation. Stretching priced out brute force. Memory-hardness priced out the hardware. SRP got the password off the wire. OPAQUE keeps every one of those wins — it still salts and stretches, with Argon2id, on a stored record — and closes the last gap by making sure the server never sees the password in the first place, with a clean proof and modern crypto underneath.
Seeing it work
The abstract version only goes so far. The reason I find OPAQUE convincing is that I built a small, self-contained demo of it — a hand-rolled TypeScript client talking to a Go server — and watched the protocol log narrate each message as it crossed the wire, knowing the password was in none of those bytes.
The walkthrough of how it actually works — the OPRF blinding, the envelope the server can’t open, the 3DH handshake, all of it down to the byte — is the companion post. After this tour of why, that’s the how.
Forty years of getting beaten and patching the bruise, and we finally have a login where there’s nothing on the wire to steal. The password is still the worst idea in security. We’ve just gotten very good at containing the blast.
Hope this was helpful to someone.