Syed Hasibur Rahman24 min read
The Username That Ate the Password: Argon2id, NIST SP 800-63B-4, and Storing Passwords in 2026
Between July and October 2024, an Okta login path accepted any password for some users, because bcrypt reads only the first 72 bytes of its input and a long username filled them. Password storage looks finished, but it still fails at the edges. This post covers the threat model, the lineage from PBKDF2 through bcrypt and scrypt to Argon2id (RFC 9106), parameters you can defend in a review, peppers, the bcrypt length trap and password shucking, and what NIST SP 800-63B-4 now requires: 15-character minimums for single-factor passwords, blocklists, NFC normalization, and no composition rules or forced rotation. It ends with tested code and a migration path for legacy hashes.
On 1 November 2024, Okta published a security advisory that reads like a puzzle with the answer printed underneath1. For three months, some users of Okta’s AD/LDAP delegated authentication could be logged in with any password. Every one of these conditions had to hold. MFA was not applied. The username was 52 characters or longer. The user had authenticated successfully before, so a cached credential existed. And the cache was consulted first, which happens when the on-premises AD/LDAP agent is down or unreachable, for example under heavy network traffic.
The cause is in one sentence of the advisory: “The Bcrypt algorithm was used to generate the cache key where we hash a combined string of userId + username + password.”1
Anyone who has read the bcrypt documentation knows the punchline. Most bcrypt implementations read at most 72 bytes of input and silently ignore the rest2. Concatenate a user ID and a long username in front of the password, and the password slides off the end of that 72-byte window. The advisory does not spell out the arithmetic, but it falls out of the limit. Once the identifier fields fill the window, two different passwords produce the same hash, and the cache cannot tell them apart. Okta’s fix, per the advisory’s timeline, was to switch the cache key from bcrypt to PBKDF21.
There is no exotic cryptanalysis here, and no nation-state. A well-regarded identity company used a well-regarded password hash for something it was not designed for, and an input property that has been documented for decades turned into an authentication bypass.
That is the state of password storage in 2026. The core idea has been settled for a long time: salt, hash slowly, compare in constant time. The failures happen at the edges, in input handling, length limits, layering, Unicode, parameter drift, and migration. Meanwhile the policy side changed underneath everyone: NIST’s SP 800-63B-4, finalised in mid-2025, now forbids the composition rules and forced rotation that many enterprise password policies still enforce3.
This post is the version I would want handed to a team building a login form this quarter. It covers the threat model, the algorithms and parameter choices you can defend in a review, peppers, the bcrypt trap and its workarounds, what NIST now requires, breached-password checks that never send the password anywhere, Unicode, a tested implementation, and how to migrate the hashes you already have.
The threat model: you are defending against your own database dump
Online guessing, an attacker typing passwords into your login form, is a rate-limiting problem. SP 800-63B-4 requires verifiers to implement “a rate-limiting mechanism that effectively limits the number of failed authentication attempts”, and caps consecutive failed attempts on a single account at no more than 1003. That is a separate topic, and this site has covered it already.
Password storage defends against a different event: your user table leaks. It might leave through a SQL injection, a misconfigured backup bucket, a compromised replica, or an employee laptop. Once the attacker has the table, there is no rate limit. They run guesses on their own hardware, as fast as their GPUs allow, for as long as they like. Your only defence is how much each guess costs.
That one framing explains every design choice that follows:
- Salt (a unique random value per password) makes each user’s hash a separate puzzle. Without it, one guess tests every account at once, and precomputed tables work. SP 800-63B-4 requires salts of at least 32 bits3. Use 128 bits; it costs nothing.
- Slowness (a tunable cost factor) multiplies the attacker’s work per guess. NIST’s wording is that the cost factor “SHOULD be as high as practical without negatively impacting verifier performance” and “SHOULD be increased over time”3.
- Memory hardness means each guess also needs a large block of RAM. That shrinks the advantage of GPUs and ASICs, which have plenty of compute but little fast memory per core.
- A pepper, a secret key kept outside the database, makes the dump useless on its own. That is the one control that changes the attacker’s situation from “slow” to “impossible without a second breach”.
Notice what is missing: encryption of the password itself. Passwords are verified, never recovered. If your system can tell a user their current password, you have built the wrong thing.
From PBKDF2 to Argon2id: thirty years in four algorithms
There are four algorithms worth knowing, and each was designed against the hardware of its time.
PBKDF2 (RFC 2898 in 2000, now RFC 8018) iterates an HMAC. It is simple, standardised, and FIPS-friendly, which is why it survives in regulated systems and why Okta reached for it. Its weakness is that it uses almost no memory, so GPUs run it extremely well. To compensate, the iteration counts have to be enormous. OWASP currently recommends 600,000 iterations of PBKDF2-HMAC-SHA256, 220,000 of HMAC-SHA512, and 1,400,000 of HMAC-SHA1 for legacy systems only2. Django raised its default PBKDF2 iteration count from 870,000 to 1,000,000 in its 5.2 release4. When a framework’s default has seven digits, the algorithm is being kept alive by brute force.
bcrypt (Provos and Mazières, 1999) builds on the Blowfish key schedule and was the first widely deployed hash with a tunable work factor. It uses a small amount of memory, 4 KiB, which hurt GPUs in 1999 and still causes them some friction today. It is everywhere, it is battle-tested, and it has the 72-byte input limit that sank Okta’s cache key. OWASP says the work factor “should be as large as verification server performance will allow, with a minimum of 10”2.
scrypt (Percival, 2009, later RFC 7914) was the first mainstream memory-hard function. Its parameter N sets memory, so cracking hardware needs real RAM per guess. Python’s standard library ships it as hashlib.scrypt, which makes it the strongest option you can use without a third-party dependency. That is why the code later in this post uses it.
Argon2 won the Password Hashing Competition, an open, multi-year process modelled on the AES and SHA-3 contests. The PHC “announced Argon2 as a winner” in July 2015, with special recognition for Catena, Lyra2, Makwa and yescrypt5. The IETF published it as RFC 9106 in September 2021 (Biryukov, Dinu, Khovratovich, Josefsson; Informational)6. It has three variants. Argon2d accesses memory in a data-dependent way, which is strongest against GPUs but exposed to side channels. Argon2i accesses memory in a data-independent way. Argon2id is the hybrid. RFC 9106 is explicit: if you are unsure which variant to use, or you consider side-channel attacks a viable threat, choose Argon2id6.
For a new system in 2026, the ranking is short. Use Argon2id. Use scrypt if Argon2 isn’t available. Use bcrypt only with the input-handling care described below. Use PBKDF2 when a compliance regime requires a FIPS-approved primitive. NIST still points to SP 800-132 for approved schemes and does not name a memory-hard function, which is a known gap between NIST and OWASP practice3.
Choosing parameters you can defend
This is where teams stall, because the documents disagree on the surface.
RFC 9106 gives two recommended Argon2id settings6:
| Option | Iterations (t) | Lanes (p) | Memory (m) | Salt | Tag |
|---|---|---|---|---|---|
| First recommended | 1 | 4 | 2 GiB | 128-bit | 256-bit |
| Second recommended (memory-constrained) | 3 | 4 | 64 MiB | 128-bit | 256-bit |
OWASP’s cheat sheet lists five minimum Argon2id configurations that trade memory against iterations, all with parallelism 1. They range from 46 MiB with t=1, through 19 MiB with t=2, down to 7 MiB with t=52.
Two gigabytes per login is a key-derivation setting, not a web-login setting. A server handling twenty concurrent logins would need 40 GiB just for hashing. In practice, the RFC’s second option, 64 MiB with t=3 and p=4, is what the mainstream libraries ship. Python’s argon2-cffi switched its defaults to the RFC_9106_LOW_MEMORY profile in version 21.2.0, and its PasswordHasher signature reads time_cost=3, memory_cost=65536, parallelism=4, hash_len=32, salt_len=167.
So here is a defensible procedure:
- Start from the RFC’s second option (64 MiB, t=3, p=4). It is above every OWASP minimum, and it is what your library already does.
- Measure on production hardware. Aim for verification in the low hundreds of milliseconds. That is a reasonable cost for a login that happens once per session.
- Budget memory for concurrency, not for one login. Peak memory is roughly concurrent verifications ×
m. If a login storm can push you past your container’s memory limit, cap concurrency with a semaphore instead of loweringm. A login that queues for 300 ms is fine. A process killed for running out of memory is an outage. - Write the parameters into the stored hash. The PHC string format (
$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>) does this for you. Then raising parameters later is a deploy, not a migration. - Raise them on a schedule. NIST says the cost “SHOULD be increased over time”3. Put a calendar entry against it, or it won’t happen.
bcrypt’s 72 bytes: the trap, the workaround, and the trap in the workaround
Back to Okta. The 72-byte limit is a property of bcrypt itself, and different libraries handle it differently. OWASP’s guidance is to enforce a 72-byte maximum password length, or less if your implementation’s limit is lower2. Some libraries have since stopped truncating silently. Go’s golang.org/x/crypto/bcrypt, for example, returns ErrPasswordTooLong from GenerateFromPassword for inputs over 72 bytes8. That is the right behaviour, and it turns a silent security bug into a loud functional one.
Two things make 72 bytes worse than it looks.
First, it is bytes, not characters. A Bengali passphrase uses three UTF-8 bytes for most characters, so 72 bytes can be as few as 24 characters. SP 800-63B-4 says verifiers SHOULD permit passwords of at least 64 characters3. A bcrypt system that rejects anything over 72 bytes can fail that SHOULD for non-Latin scripts, and a system that silently truncates fails the separate SHALL that verifiers “verify the entire submitted password (e.g., not truncate it)”3.
Second, people work around it by pre-hashing, and pre-hashing has its own failures. The classic fix is bcrypt(sha512(password)). That gives a fixed-length input regardless of password length, and it is exactly what Dropbox described in 2016. Its scheme ran SHA-512, then bcrypt at cost 10 with a per-user salt, then AES-256 encryption under a global pepper. Dropbox explicitly gave the 72-byte truncation and long-input denial-of-service as the reasons for the SHA-512 step9.
The problem is that raw hash output can contain 0x00 bytes, and some bcrypt implementations treat their input as a C string and stop at the first NUL. That silently shortens the effective input. Then there is password shucking. Scott Brady defines it as “a method of stripping layers off an updated password hash”10. Suppose your inner layer is an unsalted fast hash such as bcrypt(sha1(password)). An attacker who holds a list of SHA-1 hashes from another breach can test each of them directly against your bcrypt hashes. They never guess passwords at bcrypt speed. They only check known SHA-1 values, and every match identifies the user, after which cracking the fast inner hash is cheap.
OWASP’s resulting recommendation handles both problems: if you must pre-hash for bcrypt, use bcrypt(base64(hmac-sha384(data: $password, key: $pepper)), $salt, $cost)2. The base64 removes the NUL bytes. The HMAC key removes shucking, because nobody else’s breach contains values keyed with your pepper.
The Okta lesson generalises past passwords. Never feed a length-limited primitive a concatenation where the secret part comes last. Okta’s cache key put identifiers first and the password last, which was the worst possible order for a hash that truncates. If you need a keyed digest of several fields, use a function without a length limit, such as HMAC-SHA-256 or Argon2id with a fixed-length pre-hash. Encode the fields unambiguously with length prefixes or a canonical serialization, and write a test with a 1,000-byte username.
Peppers: the key the database never sees
Salt defends against precomputation. A pepper defends against the dump itself.
SP 800-63B-4 says verifiers SHOULD perform “an additional iteration of a keyed hashing or encryption operation using a secret key known only to the verifier”. That key “SHALL be stored separately from the hashed passwords” and SHOULD be “stored and used within a hardware-protected area, such as a hardware security module”3. OWASP draws the same line: a pepper is shared between stored passwords, unlike a per-password salt, and it must not live in the database2.
The point is to split the storage. SQL injection and backup leaks are database-shaped breaches. If the pepper sits in a KMS, an HSM, or at minimum in an environment variable injected at runtime from a secrets manager, then an attacker with only the table holds hashes they cannot even begin to test.
There are two ways to apply a pepper, and the difference matters for rotation.
- Keyed pre-hash (
argon2id(hmac(pepper, password))). This is simple. But rotating the pepper requires the plaintext password, which you only see at login. So you have to keep old peppers around, identified by a key ID stored next to each hash, until every user has logged in or been forced to reset. - Encrypt the finished hash (
aes(pepper, argon2id(password))). This is Dropbox’s design9. Its advantage is that encryption, unlike a keyed hash, lets the pepper be rotated: decrypt with the old key and re-encrypt with the new one, in a batch job, without any user involvement.
Either is fine. What is not fine is a pepper with no key ID. One day you will need to rotate it, after an employee leaves or a config file leaks, and a pepper with no version stamp turns that day into a forced reset of every user.
One caution: a pepper is defence in depth. It does not replace a slow hash. If the pepper leaks along with the table, which happens when the application server is fully compromised, then all you have left is your Argon2id parameters.
What NIST SP 800-63B-4 changed about password policy
NIST’s 2017 revision started the move away from complexity theatre. The 2025 revision, SP 800-63B-4 (Temoshok et al.), turns most of that advice into hard requirements3. The password-verifier section now reads:
Length. Passwords used as a single-factor authenticator SHALL be at least 15 characters. Passwords used only as part of multi-factor authentication MAY be shorter but SHALL be at least 8. Verifiers SHOULD permit a maximum of at least 64 characters.
Blocklist. Verifiers SHALL compare a prospective password against “a blocklist that contains known commonly used, expected, or compromised passwords”.
No composition rules. Verifiers “SHALL NOT impose other composition rules (e.g., requiring mixtures of different character types)”. The rule requiring an uppercase letter, a digit and a symbol is not merely discouraged. It is non-conformant.
No forced rotation. Verifiers “SHALL NOT require subscribers to change passwords periodically”. A change is required only when there is evidence of compromise.
No hints, no security questions. Hints accessible to an unauthenticated claimant are forbidden, and so is prompting for knowledge-based authentication.
Password managers. Verifiers “SHALL allow the use of password managers and autofill functionality”, and SHOULD allow paste. A login form that blocks paste is fighting the one tool that makes 15-character unique passwords practical.
No truncation. The entire submitted password SHALL be verified, which brings us back to bcrypt.
Unicode. Verifiers SHOULD apply Unicode Normalization Form C (NFC) before hashing.
If you run an internal tool with “Password must be changed every 90 days and contain one special character”, you are now out of line with the federal standard in two places. Every one of these changes moves cost from the user to the system. The verifier has to maintain a blocklist, accept long inputs, and normalise Unicode. That is the right trade: there are millions of users and only one verifier.
Blocklists without sending the password anywhere
A blocklist “of known compromised passwords” means, in practice, the Have I Been Pwned Pwned Passwords corpus. The obvious objection is that sending a user’s candidate password to a third party is itself a disclosure. The protocol is designed to avoid exactly that.
Troy Hunt launched the k-anonymity range API in February 2018, based on an idea from Junade Ali at Cloudflare11. You SHA-1 the password locally and send only the first five hex characters to https://api.pwnedpasswords.com/range/{prefix}. You get back every hash suffix that shares the prefix, with a breach count, and you check for a match locally. In Hunt’s example, P@ssw0rd hashes to prefix 21BD1, and the matching suffix in the response carries a count of 47,20511. The server learns only a prefix shared by hundreds of unrelated passwords.
Response size could still leak which prefix you asked for, so the API supports an Add-Padding: true header. It “pads out responses to ensure all results contain a random number of records between 800 and 1,000”, and padded rows have a count of 012. The Pwned Passwords API needs no API key, and an ?mode=ntlm variant exists for Active Directory shops12. Send the padding header.
Three implementation notes:
- Check at set time, not at every login. The SHALL applies when a password is established or changed3. You can also check at login and flag the account for a change, but that adds an external dependency to your hottest path.
- Fail open on the blocklist and closed on the hash. If HIBP is unreachable, let the password change go through, with logging. Never let a network failure weaken verification.
- Use the same normalisation everywhere. Normalise before the SHA-1 for HIBP in the same way you normalise before your password hash, or the two checks will disagree on edge cases.
Unicode: the password that works on the laptop but not on the phone
Here is a failure real users hit. Someone sets the password café au lait sans sucre on a laptop whose keyboard produces the precomposed é (U+00E9). Later they log in from a phone whose input method produces e followed by a combining acute accent (U+0065 U+0301). The two strings look identical on screen. Their UTF-8 bytes differ, so their hashes differ, and the user is locked out of an account whose password they typed correctly.
This is why NIST says SHOULD normalise to NFC3. The IETF’s detailed treatment is RFC 8265 (Saint-André and Melnikov, October 2017), which defines the PRECIS OpaqueString profile for passwords13. Its rules: map any non-ASCII space to U+0020, apply NFC to all strings, and do not apply case mapping, because lowercasing a password would “cause false accepts”. There is no width mapping and no directionality rule either.
Normalise before hashing, at registration and at every verification, in exactly the same code path. Do not “normalise more” with NFKC, case folding, or trimming. Each of those merges passwords the user typed differently on purpose, and that reduces entropy. NFC fixes only differences the user could not see.
There is one migration hazard. If you add normalisation to a system that never had it, existing hashes were computed over raw bytes. Verify against the normalised form first, fall back to the raw form, and rehash on success, as in the migration section below.
A working implementation
Below is a compact, dependency-free Python module that applies everything above. It uses hashlib.scrypt from the standard library at OWASP’s first scrypt configuration (N=2^17, 128 MiB, r=8, p=1)2. It adds a versioned HMAC pepper, RFC 8265-style preparation, parameters embedded in the stored string, a rehash signal, and an HIBP range check with padding. It was run against a test suite that checks NFC equivalence, non-truncation of 100-byte inputs, pepper rotation, and HIBP response parsing, including padded rows.
import base64, hashlib, hmac, os, unicodedata, urllib.request
# Peppers live outside the database (env, KMS, HSM). Keyed by id so they can rotate.
# Fail loudly if the pepper is missing; never fall back to a built-in default.
PEPPERS = {"p2": os.environb[b"PEPPER_P2"]}
CURRENT_PEPPER = "p2"
# OWASP's first scrypt configuration: N=2^17 (128 MiB), r=8, p=1.
SCRYPT = {"n": 2**17, "r": 8, "p": 1}
MIN_LEN, MAX_LEN = 15, 256 # SP 800-63B-4: >=15 single-factor, allow >=64
def _prepare(password: str) -> bytes:
# RFC 8265 OpaqueString, minimally: non-ASCII spaces -> U+0020, then NFC.
mapped = "".join(" " if ord(c) > 127 and unicodedata.category(c) == "Zs" else c
for c in password)
return unicodedata.normalize("NFC", mapped).encode("utf-8")
def _peppered(pw: bytes, pepper_id: str) -> bytes:
# Keyed pre-hash: fixed 32-byte input, no length limit, nothing to shuck without the key.
return hmac.new(PEPPERS[pepper_id], pw, hashlib.sha256).digest()
def _b64(b: bytes) -> str:
return base64.b64encode(b).decode().rstrip("=")
def _unb64(s: str) -> bytes:
return base64.b64decode(s + "=" * (-len(s) % 4))
def hash_password(password: str) -> str:
salt = os.urandom(16) # 128-bit salt
dk = hashlib.scrypt(_peppered(_prepare(password), CURRENT_PEPPER), salt=salt,
maxmem=256 * 1024 * 1024, dklen=32, **SCRYPT)
params = f"n={SCRYPT['n']},r={SCRYPT['r']},p={SCRYPT['p']}"
return f"$scrypt-hmac${CURRENT_PEPPER}${params}${_b64(salt)}${_b64(dk)}"
def verify_password(stored: str, password: str) -> tuple[bool, bool]:
"""Return (ok, needs_rehash)."""
_, scheme, pepper_id, params, salt, dk = stored.split("$")
if scheme != "scrypt-hmac":
raise ValueError(f"unknown scheme {scheme!r}")
p = dict(kv.split("=") for kv in params.split(","))
cand = hashlib.scrypt(_peppered(_prepare(password), pepper_id), salt=_unb64(salt),
n=int(p["n"]), r=int(p["r"]), p=int(p["p"]),
maxmem=256 * 1024 * 1024, dklen=32)
ok = hmac.compare_digest(cand, _unb64(dk))
stale = pepper_id != CURRENT_PEPPER or {k: int(v) for k, v in p.items()} != SCRYPT
return ok, ok and stale
def pwned_count(password: str, fetch=None) -> int:
"""k-anonymity lookup: only the first 5 hex chars of SHA-1 leave the process."""
digest = hashlib.sha1(_prepare(password)).hexdigest().upper()
prefix, suffix = digest[:5], digest[5:]
if fetch is None:
def fetch(pfx):
req = urllib.request.Request(
f"https://api.pwnedpasswords.com/range/{pfx}",
headers={"Add-Padding": "true", "User-Agent": "aice-lab-example"})
with urllib.request.urlopen(req, timeout=3) as r:
return r.read().decode()
for line in fetch(prefix).splitlines():
sfx, _, count = line.partition(":")
if sfx.strip() == suffix:
return int(count) # padded rows have count 0 -> treated as unseen
return 0
def check_new_password(password: str, fetch=None) -> list[str]:
problems = []
n = len(unicodedata.normalize("NFC", password))
if n < MIN_LEN:
problems.append(f"Use at least {MIN_LEN} characters.")
if n > MAX_LEN:
problems.append(f"Use at most {MAX_LEN} characters.")
try:
if pwned_count(password, fetch) > 0:
problems.append("This password appears in a public breach corpus.")
except OSError:
pass # fail open on the blocklist, never on the hash
return problems
A few decisions worth explaining:
- The pepper is applied as HMAC-SHA-256 before the slow hash. That gives scrypt a fixed 32-byte input whatever the password length, there is no NUL-byte issue because the slow hash consumes raw bytes, and nothing can be shucked without the key. The pepper ID is part of the stored string, so rotation is a matter of adding
p3, settingCURRENT_PEPPER, and lettingneeds_rehashdo the work at each login. - Comparison uses
hmac.compare_digest, which runs in constant time. MAX_LENis 256 characters, well above NIST’s 64 and far below anything that turns hashing into a denial-of-service vector. With an HMAC pre-hash, the slow hash cost does not depend on input length. But a cap still bounds request parsing and HMAC work.- Composition is never checked. Length and the blocklist are the whole policy, as NIST now requires.
If you can take a dependency, switch the slow hash to Argon2id. With argon2-cffi, the default PasswordHasher() already uses RFC 9106’s low-memory profile, and it gives you verify(), which raises VerifyMismatchError on a wrong password, and check_needs_rehash(), which reports whether a stored hash was made with the current parameters7. Feed it the same base64-encoded HMAC output as above, and keep the pepper ID in a column beside the hash.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher() # RFC 9106 low-memory profile: t=3, m=64 MiB, p=4
def hash_password(password: str) -> tuple[str, str]:
pre = base64.b64encode(_peppered(_prepare(password), CURRENT_PEPPER))
return CURRENT_PEPPER, ph.hash(pre)
def verify_password(pepper_id: str, stored: str, password: str) -> tuple[bool, bool]:
pre = base64.b64encode(_peppered(_prepare(password), pepper_id))
try:
ph.verify(stored, pre)
except VerifyMismatchError:
return False, False
return True, pepper_id != CURRENT_PEPPER or ph.check_needs_rehash(stored)
Migrating the hashes you already have
Most teams reading this are not starting fresh. They have a column full of bcrypt, or PBKDF2 at 10,000 iterations, or something worse. You cannot rehash a hash into a better one without the password. You have two tools, and you should usually use both.
1. Rehash on login. Every successful login gives you the plaintext for a moment. Verify against the legacy scheme, and if it matches, compute the new hash and overwrite the old one in the same transaction. argon2-cffi documents this exact pattern for parameter upgrades: rehash “at the next opportunity”, ideally at login7. Active users migrate within weeks.
2. Wrap the dormant ones. Users who don’t log in keep their weak hashes in your table indefinitely, and those are the hashes the next breach exposes. Wrap them now: new = argon2id(old_hash), with a marker that this row is wrapped. At login you compute argon2id(legacy(password)), compare, and then replace the row with a clean argon2id(password).
Wrapping is exactly the layered construction that password shucking attacks, so do it with care. If the legacy hash is salted and slow (bcrypt, PBKDF2 with per-user salts), shucking gains the attacker little, because the inner hash is already per-user and expensive. If the legacy hash is unsalted and fast (raw MD5 or SHA-1, which is still found in old PHP systems), wrap it under a keyed outer layer by putting the pepper in, argon2id(hmac(pepper, md5_hex)). Then known-hash lists from other breaches cannot be tested against your rows10. Then set a deadline. After some number of months, force a reset for whoever is still wrapped.
Normalisation changes are a migration too. If you are adding NFC to an existing system, verify the normalised input first, fall back to the raw bytes, and rehash on whichever succeeds. Without the fallback, every user who set a password with a combining character is locked out the day you deploy.
Operational edges
A few things the specs leave to you, which cause trouble in production:
The login endpoint is a denial-of-service amplifier. Each attempt costs you 64 MiB and hundreds of milliseconds by design. An unauthenticated attacker can make you pay that cost as often as they can send requests. Rate-limit before hashing: per IP, per account, and globally. Bound concurrent hash operations with a semaphore so a flood queues instead of exhausting memory. This is also why the input-length cap belongs at the HTTP layer.
Timing should not reveal which accounts exist. If unknown usernames return before the hash step, response time tells an attacker which emails are registered. Verify against a dummy hash with identical parameters when the user doesn’t exist. Our earlier post on anti-enumeration covers this in detail, including the requirement that the dummy use the same algorithm and cost as real hashes.
Do not reuse password hashes as keys or cache identifiers. Okta’s bug was not in password storage. It was in a cache key that reused a password-hashing primitive1. If you need a deterministic identifier for “this credential was verified recently”, use HMAC over a canonical encoding, with an explicit expiry. The same cache arguably shouldn’t accept a credential while the authority that issues it is unreachable, but that is a policy question, separate from the cryptography.
Log the rehash rate. A dashboard showing the share of rows on current parameters tells you when you can drop legacy code paths and when to force resets. Without one, you will be supporting MD5 verification in 2031.
Write the boring tests. Write one test with a 1,000-byte password, one with a 1,000-byte username, one with NFC versus NFD input, one with a password containing U+00A0, and one that passes two passwords differing only after byte 72. The Okta bug would have failed the last one on the first run.
Closing: passwords are not solved, they are bounded
It is tempting, in a year when passkeys are finally mainstream, to treat password storage as legacy work. That is a mistake in both directions. Passwords will be the fallback, the recovery path, and the only credential for a long list of internal tools for the rest of this decade. Bangladesh’s government, banking and education systems, which are where much of this lab’s practical work happens, will run password logins long after the consumer web has moved on.
Password storage is not hard. It is bounded: a small set of decisions, each with a documented right answer, and each with a documented way to get it wrong. Use Argon2id at 64 MiB and t=3, or scrypt from the standard library, with a 128-bit salt. Apply a versioned pepper held outside the database. Normalise to NFC, and never truncate. Require 15 characters and check a breach corpus through k-anonymity. Remove composition rules and forced rotation, because NIST now forbids them. Rehash on login, and wrap what stays dormant.
None of that is new. Okta shipped a bypass anyway, because one of those boundaries, the 72-byte limit, was crossed by code that never called itself password storage. The real discipline is to recognise every place a secret meets a hash function, and to apply all of the above there too.
References
Okta, “Okta AD/LDAP Delegated Authentication - Username Above 52 Characters Security Advisory”, 1 November 2024. https://trust.okta.com/security-advisories/okta-ad-ldap-delegated-authentication-username/ ↩︎ ↩︎ ↩︎ ↩︎
OWASP Cheat Sheet Series, “Password Storage Cheat Sheet”. https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Temoshok, D., Fenton, J., Choong, Y., Lefkovitz, N., Regenscheid, A., Galluzzo, R., and Richer, J., “NIST Special Publication 800-63B-4: Digital Identity Guidelines — Authentication and Authenticator Management”, National Institute of Standards and Technology, July 2025. https://csrc.nist.gov/pubs/sp/800/63/b/4/final (HTML edition: https://pages.nist.gov/800-63-4/sp800-63b.html) ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Django Software Foundation, “Django 5.2 release notes”. https://docs.djangoproject.com/en/5.2/releases/5.2/ ↩︎
Password Hashing Competition. https://www.password-hashing.net/ ↩︎
Biryukov, A., Dinu, D., Khovratovich, D., and Josefsson, S., “Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications”, IETF RFC 9106, September 2021. https://www.rfc-editor.org/rfc/rfc9106.html ↩︎ ↩︎ ↩︎
Schlawack, H., “argon2-cffi API Reference”. https://argon2-cffi.readthedocs.io/en/stable/api.html ↩︎ ↩︎ ↩︎
The Go Authors, “golang.org/x/crypto/bcrypt” package documentation. https://pkg.go.dev/golang.org/x/crypto/bcrypt ↩︎
Akhawe, D., “How Dropbox securely stores your passwords”, Dropbox Tech Blog, 21 September 2016. https://dropbox.tech/security/how-dropbox-securely-stores-your-passwords ↩︎ ↩︎
Brady, S., “Beware of Password Shucking”, 13 April 2021. https://scottbrady.io/authentication/beware-of-password-shucking ↩︎ ↩︎
Hunt, T., “I’ve Just Launched Pwned Passwords Version 2”, 21 February 2018. https://www.troyhunt.com/ive-just-launched-pwned-passwords-version-2/ ↩︎ ↩︎
Have I Been Pwned, “API v3 — Pwned Passwords”. https://haveibeenpwned.com/API/v3 ↩︎ ↩︎
Saint-André, P., and Melnikov, A., “Preparation, Enforcement, and Comparison of Internationalized Strings Representing Usernames and Passwords”, IETF RFC 8265, October 2017. https://www.rfc-editor.org/rfc/rfc8265.html ↩︎