On October 30, 2024, Okta published a security advisory with an unusual failure mode: under specific conditions, a user of AD/LDAP delegated authentication could log in with their username "and the stored cache key of a previous successful authentication" — in practice, without presenting the correct password. No cryptographic break, no leaked secret. The root cause was a property of bcrypt that has been sitting in its source code since 1997: it silently stops reading its input at byte 72.

Okta's advisory doesn't spell out the mechanism — it only says the cache key was "generated using the Bcrypt algorithm, which hashed a combined string of userId + username + password," and that the fix was switching to PBKDF2. But once you know how bcrypt consumes a key, the rest of the advisory derives itself, including the oddly specific precondition of "a username that is 52 characters or longer."

Where the number 72 comes from

bcrypt, designed by Niels Provos and David Mazières and presented at USENIX in 1999, is built on the Blowfish cipher's key schedule. Blowfish keeps its key material in a P-array of 18 subkeys of 32 bits each — 18 × 4 = 72 bytes of state. The ExpandKey step mixes the password into that state by XOR: the first 32 bits of the key into P1, the next 32 bits into P2, and so on, with the key treated as cyclic — when it runs out, mixing wraps back to the key's first byte.

That cyclic wrap is the whole story. By the time the XOR walk reaches P18 it has consumed 72 bytes. Byte 73 of your password would only matter if there were a P19 to receive it. There isn't, so implementations don't even pass it in. From the OpenBSD lineage of bcrypt.c (this copy from NetBSD):

c
len = strlen(key);
if (len > 72)
        key_len = 72;
else
        key_len = (uint8_t)len;
key_len += minor >= 'a' ? 1 : 0;

No error, no warning, no return code. Everything after byte 72 is discarded before key setup begins.

Two details in that snippet reward a closer look. First, strlen(key) means the key stops at the first NUL byte — relevant if you ever feed bcrypt the raw output of another hash function, which can contain 0x00 anywhere. Second, the $2a$ variant adds one byte for the string's NUL terminator; the source comments explain why: "'ab' should not yield the same as 'abab'." Without the terminator, the cyclic key wrap would make those two passwords mix identical bytes into the P-array.

There's even a discrepancy between the paper and the code: the 1999 paper describes the key as "a user-chosen password of up to 56 bytes (including a terminating zero byte when the key is an ASCII string)" — Blowfish's documented key limit — while every mainstream implementation accepts up to 72, the full P-array. The limit people rely on in production was never the one in the paper.

After key setup — an expensive loop that re-runs ExpandKey 2^cost times, alternating password and salt — bcrypt encrypts the 24-byte magic string "OrpheanBeholderScryDoubt" 64 times in ECB mode and emits the result. None of that later machinery ever sees your truncated bytes either.

20 + 52 = 72: the Okta arithmetic

Now put a concatenation in front of it. Okta's DelAuth cache key was, per its advisory and subsequent analysis by Joseph Scott, effectively:

bcrypt(userId + username + password)

with no separators and no length enforcement. Okta userIds are 20 characters. 72 − 20 = 52: the moment a username reaches 52 characters, the password contributes zero bytes to the hash. Two cache keys for that user — one computed with the real password, one computed with anything at all — are byte-for-byte identical inputs as far as bcrypt is concerned, so verification passes.

Scott demonstrated the collision in a few lines of PHP: with a short username (zerocool95), password_verify() correctly rejects a wrong password; pad the username past the threshold and the wrong password verifies, because the bytes that differ all fall after position 72.

Exploitation still required a stack of conditions, all listed in the advisory: delegated AD/LDAP auth in use, no MFA, a username of 52+ characters, a cache entry from a prior successful login, and the cache being consulted first — "this can occur if the AD/LDAP agent was down or cannot be reached, for example due to high network traffic." The bug shipped on July 23, 2024 and was found internally 99 days later. Okta's remediation was telling: not "enforce a length limit," but replace bcrypt with PBKDF2 for cache key generation — an algorithm with no input ceiling to fall off.

The design lesson is sharper than "bcrypt truncates." A password hash is the wrong tool for deriving a cache key from a composite string in the first place, because concatenation plus a silent input limit is a collision machine: the attacker doesn't need to find a collision in the hash, they just need to push the secret past the part of the input the hash actually reads.

The other truncation bug: wrap at 256

Truncation at 72 is at least documented behavior. In January 2012, Solar Designer reported a bug on oss-security that was not: OpenBSD's bcrypt.c computed the key length into a u_int8_t, so the length wrapped modulo 256 before the 72-byte cap was applied. His test on OpenBSD 4.6: a 256-character password starting with "0" hashed to the same value as the single-character-repeated password of 72 zeros — "substantially different behavior than truncation," since characters 2 through 72 could hold all the entropy and still be ignored.

OpenBSD's current source fixes this per minor version, and the comment is admirably blunt:

c
case 'b':
        /* strlen() returns a size_t, but the function calls
         * below result in implicit casts to a narrower integer
         * type, so cap key_len at the actual maximum supported
         * length here to avoid integer wraparound */
        key_len = strlen(key);
        if (key_len > 72)
                key_len = 72;
        key_len++;      /* include the NUL */
        break;

Note what's directly above it: the $2a$ case still computes (u_int8_t)(strlen(key) + 1) — the wraparound is deliberately preserved for old hashes, because "fixing" it would invalidate every $2a$ hash of a long password already in a database. That's why the version prefix exists: bcrypt_gensalt in the same file now emits $2b$. Hash format version bumps are how password hashing ships bug fixes without locking users out.

What to actually do

  • Never concatenate anything in front of a secret that feeds into bcrypt. If a composite identifier is needed, use a KDF without an input limit, or hash the components first — that's what Okta's PBKDF2 switch achieves.
  • Enforce the limit at the edge. Reject or at least measure passwords over 72 bytes, not characters — UTF-8 multiplies, so 24 emoji can exhaust the budget.
  • Pre-hashing needs care. bcrypt(sha256(password)) removes the limit, but raw digest bytes can contain 0x00, and as the source shows, strlen() stops there. Encode the digest (base64/hex) before passing it in.
  • Audit assumptions when the input isn't a human password. bcrypt's limit is a reasonable trade-off for passwords typed by people. The Okta bug happened precisely when the input stopped being one.

The 72-byte ceiling isn't a flaw in bcrypt; it's the P-array's size, visible in the first page of the paper's algorithm description. The flaw is treating a password hash as a general-purpose hash. bcrypt reads exactly 18 subkeys' worth of your input, and it has been politely ignoring the rest for twenty-five years.

References