·
The Long Goodbye: Password Hashing in the Age of Passkeys
Passwords are being phased out, and you still have to store them correctly. A practical tour of hashing, the attack vectors that actually get used, and the one failure mode nobody warns you about: your own defense becoming a denial-of-service vector.
A password is a strange kind of secret. It is the only one your users will type into whatever box looks approximately correct, reuse across forty other services, and expect you to store forever without ever losing it or reading it.
Every serious identity vendor is working to kill it. Apple, Google, and Microsoft ship passkeys by default. The FIDO Alliance has spent a decade building the replacement. And yet, if you operate an authentication system in 2026, you’re still storing password hashes. And you will be for years.
This article covers what actually matters about that: how hashing works, which attacks are real versus theatrical, and a failure mode that gets almost no coverage despite being trivially reachable: the moment your password defense turns into a denial-of-service vector against yourself.
Why you cannot just encrypt them
The most common conceptual mistake in password storage is reaching for encryption.
Encryption is reversible by design. That is the entire point: you hold a key, and with it you can recover the plaintext. Which means an attacker who reaches your database has an obvious next move: go find the key. It’s on the same machine, or in the same secret manager, or in the environment of the same process that just got compromised. Password databases and the keys that would decrypt them have an unfortunate tendency to be stolen together.
Hashing is different. A cryptographic hash is a one-way function: easy to compute forward, computationally infeasible to reverse. You never store the password. You store the output of a function applied to it, and at login you apply the same function to what the user typed and compare the results.
The consequence is worth stating plainly: a correctly built authentication system cannot tell you your own password. If a service can email you your existing password, it is not hashing it. That is not a minor implementation detail — it is a complete architectural tell.
The problem with fast hashes
Here is where the first generation of password storage went wrong.
MD5 and the SHA family are cryptographic hashes, and they are one-way. They are also engineered to be fast, because they were designed for checksums and signatures, where speed is a feature.
For password storage, speed is the vulnerability.
An attacker holding your dumped hash table does not need to reverse the function. They just guess: run candidate passwords through the same hash and look for matches. This is an offline attack. No rate limiting, no lockouts, no logging, no network. Just their hardware against your algorithm choice.
And modern hardware is extraordinary at this. A single high-end consumer GPU computes MD5 on the order of a hundred billion hashes per second. SHA-256 is slower but still in the tens of billions. Against a list of common passwords, leaked corpora, and dictionary mutations, “the whole realistic keyspace” falls in minutes.
One early attacker optimization deserves a name here: rainbow tables, precomputed hash-to-password lookups. The defense against them is the first thing every password system needs.
Salt: making every hash a private problem
A salt is a random value, unique per password, stored alongside the hash and mixed in before hashing.
It is not secret. It does not need to be. Its job is narrower and cleverer than most explanations suggest:
- It kills precomputation. A rainbow table built for the whole internet is useless against your database, because every one of your hashes was computed with a different random value.
- It kills batch cracking. Without salt, an attacker cracks all 10 million of your users simultaneously, because one guess is tested against every row at once. With unique salts, each user must be attacked individually. Ten million users means ten million separate cracking jobs.
- It hides collisions. Without salt, two users with the same password have the same hash, which is visible at a glance in a dump.
In Versola, every stored password record carries its own 16 bytes from a CSPRNG. That is the baseline; nothing about it is optional.
Pepper: the part that stays out of the database
A pepper is a second secret ingredient. Unlike salt, though, it’s genuinely secret, shared across records, and deliberately stored somewhere other than the database.
The threat it addresses is specific and very common: the attacker who obtains a database dump but not the application host. SQL injection, a leaked backup, an over-permissioned analyst account, a misconfigured replica. In all of these, the attacker gets the table and nothing else.
Salt doesn’t help there; it’s in the same dump. A pepper does, because without it, the stolen hashes cannot be attacked at all. The candidate guesses will never produce matching output, no matter how much hardware is thrown at them.
In Versola, the pepper is injected as Argon2’s associated-data parameter and sourced from application configuration, never from the database:
val params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
.withIterations(Argon2Iterations)
.withMemoryAsKB(Argon2MemoryKiB)
.withParallelism(Argon2Parallelism)
.withSalt(salt) // per-record, stored in the DB
.withAdditional(pepper) // deployment-wide, never in the DB
.build()
OWASP is careful to frame pepper as defense in depth rather than a core control, and that framing is right. It buys you nothing against an attacker who owns the application host. But the database-only breach is such a well-trodden path that the protection is worth having.
Memory hardness: why Argon2id won
Salt and pepper change the economics of an offline attack. They do not change the per-guess cost. For that you need a hash function that is deliberately, tunably slow.
The first generation of these, PBKDF2 and bcrypt, worked by iterating: run the inner function thousands of times. That raises attacker cost linearly, which helped, until specialized hardware arrived. GPUs and FPGAs are very good at running many small, independent computations in parallel, and iteration alone does not stop them.
The insight behind memory-hard functions is to attack the economics from a different direction. Computation is cheap to parallelize in silicon. Memory is not. Transistors for arithmetic are small and getting smaller; RAM is bulky, power-hungry, and expensive per unit.
So Argon2 forces each hash computation to fill and repeatedly access a large block of memory. The arithmetic is now the easy part; the constraint is memory bandwidth and capacity.
Run the numbers. Configured at 19 MiB per hash, a GPU with 24 GB of VRAM can hold roughly 1,290 concurrent Argon2id instances, and that’s a hard ceiling from capacity alone, before bandwidth contention. The same card was doing a hundred billion MD5s per second. Against Argon2id at this configuration it manages a few thousand per second. That is a cost increase of roughly seven orders of magnitude, and it comes from making the attacker buy memory rather than compute.
Argon2id specifically is the hybrid variant: it starts in a data-independent memory access pattern (resisting side-channel attacks that watch memory access timing) and then switches to data-dependent access (resisting time-memory tradeoff attacks). It is the variant to use unless you have a specific reason otherwise.
| Algorithm | Work factor | GPU/ASIC resistance | Verdict |
|---|---|---|---|
| MD5 / SHA-256, unsalted | none | none | Broken for passwords |
| PBKDF2-HMAC-SHA256 | iterations | weak (compute-bound) | Acceptable when mandated by compliance |
| bcrypt | cost factor | moderate (4 KiB working set) | Fine; watch the 72-byte input cap |
| scrypt | N, r, p | strong | Good alternative |
| Argon2id | m, t, p | strong | Default choice for new systems |
OWASP’s baseline for Argon2id is m=19456 (19 MiB), t=2, p=1. Versola uses exactly that. The listed alternatives (46 MiB with t=1, or 12 MiB with t=3, and so on) trade memory against iterations at equivalent strength — pick based on which resource you have to spare.
The part nobody warns you about
Now the twist, and the reason this article exists.
You have just deliberately made password verification expensive. Every login allocates 19 MiB and burns real CPU time. That was the goal.
Now consider who can trigger that computation.
The login endpoint is, necessarily, unauthenticated. Anyone on the internet can reach it. You have built a machine that converts a single cheap HTTP request into 19 MiB of your memory and tens of milliseconds of your CPU. Then you published its address.
The cost asymmetry that protects you offline is inverted online. The attacker spends nothing. You spend everything.
The arithmetic is unforgiving. At 200 login attempts per second with a 75 ms hash, roughly 15 hashes are in flight at any moment: about 285 MiB resident, purely in hashing working set. That’s not a sophisticated attack. That’s a modest traffic spike, or a mildly enthusiastic credential-stuffing run, on a service with a 512 MiB memory limit.
And it usually fails in the worst possible way. Most runtimes dispatch this kind of blocking work to a thread pool that grows on demand. There is no natural back pressure. Concurrency scales with inbound traffic until the process runs out of heap, and then it doesn’t degrade. It dies. Not just for logins. For everything the process was serving.
The fix is admission control: a bounded semaphore in front of the hashing operation.
hashingSemaphore <- Semaphore.make(argon2Config.maxConcurrent.toLong)
With a cap of 12, worst-case hashing memory is fixed at roughly 228 MiB no matter what arrives. Requests beyond the cap don’t fail and don’t each claim hashing memory. They wait, and on a fiber-based runtime a waiting fiber is genuinely cheap: a suspended continuation costing a few hundred bytes, not a blocked OS thread costing a megabyte of stack.
But a permit queue that grows without limit is its own outage. Every waiting request still owns an open connection, and everything attached to that connection — buffers, headers, the fiber itself — sits in memory until it either gets a permit or times out. Bound only the hashing and an attacker can open far more connections than you can ever hash, park all of them in the queue, and exhaust connection memory, file descriptors, or the reverse proxy’s connection table without a single Argon2id call running. The semaphore fixed one resource and left another one open.
The queue needs its own explicit bound: cap how many requests may wait, reject with a 503 once that cap is hit instead of growing the queue further, and put a timeout on the wait so a permit that never frees doesn’t pin a connection indefinitely. Only with both the concurrency cap and the queue cap in place does overload turn into graceful degradation instead of an outage — latency rises, the bounded queue absorbs the burst, requests past the queue’s capacity are shed immediately, and every other endpoint keeps serving.
Size both caps against your actual memory and connection limits, and treat them as deployment-time knobs rather than constants, because the right values depend entirely on the container and proxy they run behind.
But a semaphore only protects the process. It says nothing about whether the traffic itself is legitimate, and that is a separate problem that needs a separate control: rate limiting.
The two mechanisms solve different failure modes and neither substitutes for the other:
- Admission control answers “how much of this can my process survive at once?” It is a resource question, scoped to a single instance, and it activates only under load. It will happily let a determined attacker queue up thousands of guesses against one account — slowly, patiently, forever — as long as they never exceed the concurrency cap.
- Rate limiting answers “how much of this should any single client be allowed to send?” It is a policy question, and it has to be enforced before the request reaches the hash, or it does not help at all.
Rate limiting a login endpoint needs more than one axis, because attackers pick whichever axis you left open:
- Per-account. Caps how many attempts a single username can absorb, the classic defense against a brute-force run at one target. Set it too low, though, and you have built a trivial account-lockout weapon: an attacker who only knows a victim’s email can lock them out on demand by spraying wrong guesses.
- Per-IP. Catches the single machine hammering many accounts. Nearly useless alone against botnets or credential-stuffing lists distributed across thousands of residential proxy IPs, but cheap and worth having as a floor.
- Per-subject, beyond just IP. A device fingerprint, a client ID, an ASN — anything that survives IP rotation. Credential stuffing tooling rotates IPs specifically to dodge the per-IP limit, so the limiter needs a second identity to key on.
- Global. A circuit breaker on the endpoint as a whole. If aggregate login volume triples in five minutes, something is wrong regardless of which account or IP is responsible, and that is worth reacting to even before any single-subject threshold trips.
The failure mode to design against is a limiter that only fires after the expensive work has already run. Checking the counter after the Argon2id call instead of before it defeats the entire point, since the memory and CPU are already spent by the time you decide to reject the request. Rate limiting has to sit in front of admission control, not behind it: reject first, hash second.
None of this replaces the semaphore, and the semaphore does not replace this. Rate limiting keeps hostile traffic from reaching the hashing path at meaningful volume; admission control makes sure that whatever does get through — including the legitimate traffic you can’t and shouldn’t block — cannot exhaust the process by itself. You want both, because each one is exactly the backstop for what the other misses.
Amplification: check your own multipliers
Once you start thinking of hashing as a metered resource, the next question follows naturally: does any single request trigger more than one hash?
This is where password history policies deserve scrutiny. To enforce “you cannot reuse a recent password,” you must check the submitted value against stored historical hashes, and because each historical record has its own salt, you can’t batch it. You hash once per record, sequentially.
Consider the flow. A user submits a wrong password. You hash it against the current record: no match. Then, to produce a helpful “that appears to be an old password” message, you walk the history: hash, hash, hash.
With a history size of five, a single wrong password submission costs up to six Argon2id computations. That is a 6x amplification factor on the exact request type an attacker sends most, and wrong passwords are precisely what credential stuffing generates in volume. The security value of blocking, say, the fifth-most-recent password is marginal; the cost multiplier from checking it on every failed attempt is not.
The general principle: audit your authentication paths for anything that turns one request into N hashes. History checks are the common culprit, but any “check this against a list of stored secrets” feature has the same shape. Keep the list short by default, and let deployments with a genuine compliance requirement opt into a larger one deliberately, with the memory budget to match.
The attacks that actually happen
Offline cracking gets the most attention in writing about passwords. It is not what is emptying accounts this year. The realistic threat list, roughly in order of how often it succeeds:
Credential stuffing. Attackers take username/password pairs from other services’ breaches and replay them against yours. No cracking required, because your hashing is irrelevant — they have the plaintext already, and your user reused it. Your only real defenses are breach-corpus checking at registration, rate limiting, and a second factor.
Phishing and adversary-in-the-middle. A convincing proxy page relays the login in real time, harvesting the password and the one-time code. This defeats TOTP and SMS second factors completely, because the user genuinely authenticates — just to the attacker’s proxy, which forwards everything. Tooling for this is off-the-shelf.
Password spraying. One very common password against thousands of accounts. Designed specifically to stay under per-account lockout thresholds — which is why per-account rate limiting alone is insufficient, and you need per-IP and global anomaly limits as well.
Recovery-flow abuse. Why attack the front door when the “forgot password” path exists? Recovery is frequently the weakest authenticator in the system, and attackers know it.
User enumeration. A subtler one, and it interacts directly with everything above. If your endpoint returns faster for a nonexistent user than a real one, that timing difference is a membership oracle for your user list. This creates a genuine design tension: skipping the hash for unknown users is excellent for DoS resistance and bad for privacy. Resolve it deliberately — uniform response shapes, and uniform latency for the unauthenticated caller — rather than by accident.
Note what this list implies. Argon2id tuning does nothing against most of it. Password storage is table stakes; it is not the same thing as authentication security.
What modern guidance actually says
NIST SP 800-63B-4 reached final publication in July 2025, and it formalizes advice that overturns a generation of corporate password policy. The headline items:
- No composition rules. Mandatory uppercase-plus-digit-plus-symbol requirements SHALL NOT be imposed. They push users toward predictable patterns (
Password1!) while adding little real entropy. - No scheduled rotation. Forced 90-day expiry is out, except on evidence of compromise. Rotation reliably produces
Summer2026, thenSummer2027. - Check against blocklists. Verifiers SHALL compare new passwords against known-compromised and commonly-used values. This is the single highest-value control you can add, and services like Have I Been Pwned expose it through a k-anonymity API that never receives the password or its full hash.
- Length over complexity. A password used as the sole factor SHALL be at least 15 characters; the 8-character floor is only permitted when the password rides alongside a second factor. Accept at least 64 characters, including spaces and Unicode. Never truncate, and never block paste — that breaks password managers, which are the tool most likely to improve your users’ actual security.
If your policy still enforces quarterly rotation and symbol requirements but does not check breach corpora, you have inverted the priority order.
The world is moving on, and here is the “but still”
Passkeys are not another password variant with better hygiene. They are a categorically different construct, and the distinction is worth being precise about.
A passkey is a public/private key pair. The private key never leaves the user’s device. Authentication is a challenge-response signature, and the browser cryptographically binds it to the origin that requested it.
Follow the consequences:
- There is no shared secret to steal. You store a public key. A full database dump yields nothing worth cracking. The entire offline-attack discussion above becomes irrelevant.
- There is nothing to reuse. Every credential is unique per site by construction, so credential stuffing has no input.
- There is nothing to phish. This is the one that matters most. Origin binding is enforced by the browser, not by the user’s judgment. A proxy on
versola-login.example.comcannot obtain a signature valid forversola.com. The attack that defeats TOTP simply does not function.
That last property is why the industry is not merely improving passwords but replacing them.
And yet.
Passwords will outlive most predictions about them, for reasons that are structural rather than technical:
Recovery is the floor, and it is made of something else. A user loses their phone. What now? Whatever that path is (an email link, an SMS code, a support call) becomes the true security level of the account. You can deploy perfect passkeys and still be exactly as strong as your users’ email provider. The hardest part of passwordless is not the passkey; it is designing recovery that is neither a backdoor nor a permanent lockout.
Bootstrapping needs a first credential. Something has to authenticate the user before the first passkey is registered.
Coverage remains uneven. Shared workstations, kiosks, corporate machines with locked-down browsers, older devices, regional platform gaps, and a long tail of enterprise systems that will not support WebAuthn on any timeline you control.
Sync introduces a new dependency. Passkeys that sync across devices, which is what makes them usable at all, mean the security of the credential now partly depends on the user’s platform account and its recovery process. Real improvement, not an absolute.
So the realistic trajectory is not deletion. It is demotion: passwords stop being the primary authenticator and become the fallback.
Which produces one final, uncomfortable observation. Fallback paths receive less design attention, less testing, and less monitoring than primary paths, and attackers move to exactly where the attention is not. As passkey adoption grows, the password path becomes simultaneously less used and more attractive as a target. It gets easier to neglect at the precise moment neglecting it becomes more dangerous.
That is the argument for getting this right even while you are working to make it obsolete.
What this does not solve
The admission-control numbers above are per process. A semaphore caps concurrency on one instance; it says nothing about the same account getting hit across ten instances behind a load balancer, each running its own hashing budget in blissful ignorance of the other nine. Cluster-wide limits need a shared store, Redis or a database counter, and that store becomes its own capacity problem once login traffic is heavy enough to need one.
The 200 req/s and 285 MiB figures are illustrative, not a sizing guide. Real numbers depend on your Argon2id parameters, your container’s memory limit, and how much of that memory is already claimed by everything else in the process. Measure your own instance before picking a semaphore size; don’t copy these.
Breach-corpus checking, ranked above composition rules earlier in this article, is an external dependency: a k-anonymity API call, or a corpus you maintain yourself. It adds latency to every registration and password change, and none of it is something Argon2id or a semaphore gives you for free.
None of this is exotic. It is homework that has to happen before the numbers above mean anything for your specific deployment.
The short version
If you store passwords, in rough priority order:
- Argon2id,
m=19456, t=2, p=1minimum. Not MD5, not SHA-256, not “SHA-256 but salted.” - Unique random salt per record, 16 bytes from a CSPRNG.
- A pepper stored outside the database. Cheap defense in depth against dump-only breaches.
- Bound your hashing concurrency. A semaphore sized to your memory limit. This is the step almost everyone skips, and it is the difference between slow and down.
- Audit amplification. Count how many hashes one request can trigger. History walks are the usual culprit.
- Check new passwords against breach corpora. Higher real-world value than any composition rule.
- Drop composition rules and scheduled rotation. Current NIST guidance, and better for users.
- Rate limit on multiple dimensions. Per account, per IP, and globally.
- Treat recovery as a first-class authenticator, because attackers already do.
- Ship passkeys, and design the fallback path as though it will be attacked — because it will be.
The goal is not a perfect password system. It is a system where the password matters a little less with every release, and where the day you finally turn it off, nothing of value is lost.