The New Default. Your hub for building smart, fast, and sustainable AI software
Table of Contents
bcrypt silently ignores every byte of a password past the 72nd, so the fix is to reject longer passwords explicitly or move to Argon2id. A subtle implementation flaw, like bcrypt's 72-byte limit, can undermine the security model without any error or warning. Nothing is raised or logged, and the user is never told. And in a pepper-based setup, a sufficiently long password can silently drop the pepper from the hash entirely.
This post explains the problem, shows it in Ruby with concrete examples, and covers the three approaches to handling it, including why the naive pre-hashing approach that's often suggested is dangerous. Our Ruby on Rails team has been building production Rails systems since 2008. That includes Seat Unique, a ticketing marketplace handling 1M+ monthly visitors and 6,000 transactions a month.
Executive Summary
bcrypt caps password input at 72 bytes and silently ignores anything beyond that limit, with no error or warning telling the developer or user that truncation happened.
The risk compounds with a pepper: a long password can push the pepper past that boundary and drop it from the hash entirely, undermining the reason for using one.
Existing bcrypt systems can close the gap with an explicit byte-length check, while new systems should use Argon2id, which OWASP now recommends and which doesn't have the problem.
Why Is bcrypt's 72-Byte Limit a Real Production Risk?
The 72-byte limit doesn't raise an error, so a test suite asking "does login work?" won't catch it. It gets caught by attackers, or by nobody at all. Passwords end up weaker than the team believes, and pepper disappears from the hash it was meant to protect, with no signal that either happened.
This has already reached production in mature, widely used systems. A HackerOne report against GitLab documented exactly this truncation in 2020. Spring Security shipped the equivalent bug as CVE-2025-22228, described in Spring's own advisory as: "BCryptPasswordEncoder.matches(CharSequence,String) will incorrectly return true for passwords larger than 72 characters as long as the first 72 characters are the same."
There are three ways to close this gap, and they trade off differently:
Approach | Effort | Security level | Migration cost | Best for |
|---|---|---|---|---|
Migrate to Argon2id | Medium – new library, re-hash strategy | Highest – OWASP's primary recommendation; no practical length limit | Passwords re-hash opportunistically at login | New systems, or existing ones with room to migrate |
Add length validation (stay on bcrypt) | Low – a few lines of validation code | Baseline – closes truncation and pepper-drop, but OWASP still lists bcrypt as legacy | None – no hash migration | Existing systems that can't move off bcrypt right now |
Pre-hash before bcrypt | Medium-high – must implement keyed HMAC correctly | Depends entirely on implementation – OWASP calls a naive version dangerous | None – no algorithm change | Legacy systems that can't touch the bcrypt call itself |
What's the Problem with Bcrypt's Security?
Here's what the failure looks like in practice. A slightly-too-long password produces what looks like a valid hash, and it validates correctly:
irb(main)> password = "a" * 72 + "someextra"
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaasomeextra"
irb(main)> hash = BCrypt::Password.create(password)
=> "$2a$12$ijB8LuN33Y6.NoMl3gTLieyd9hYzp7SWD4H6immKiy80DNCTXYgQ."
irb(main)> BCrypt::Password.new(hash) == password
=> trueThe problem shows up when you compare that same hash to only the first 72 "a"s, with "someextra" dropped entirely. It still validates:
irb(main)> hash
=> "$2a$12$ijB8LuN33Y6.NoMl3gTLieyd9hYzp7SWD4H6immKiy80DNCTXYgQ."
irb(main)> BCrypt::Password.new(hash) == "a" * 72
=> trueAs long as the first 72 bytes still make a secure password, this might seem acceptable, but that assumes users create secure passwords, which isn't guaranteed. With a pepper, the same silent truncation removes it from what gets hashed:
irb(main)> password = "a" * 72 + pepper
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaapepper"
irb(main)> hash = BCrypt::Password.create(password)
=> "$2a$12$kMR90psu4jhtp2xb7B35d.36lDuyewZsPEeDRXiVdlrWr9B7CQMVi"
irb(main)> BCrypt::Password.new(hash) == "a" * 72
=> trueThe limit is 72 bytes, and not 72 characters, which matters because most web passwords use UTF-8, and not every character is 1 byte. Monterail is a Polish company, and Polish uses a few extra letters, like "ą", which takes 2 bytes in UTF-8:
irb(main)> "ą".bytesize
=> 2A password built only from "ą" characters has its effective length cut to 36:
irb(main)> password = "ą" * 36 + "extra"
=> "ąąąąąąąąąąąąąąąąąąąąąąąąąąąąąąąąąąąąextra"
irb(main)> hash = BCrypt::Password.create(password)
=> "$2a$12$3QyhYPBdqknleoFPiDHn5edo.OF4jgU4D0dYOa/MDncm5EDy0QUB."
irb(main)> BCrypt::Password.new(hash) == "ą" * 36
=> trueIn practice, Polish passwords mix Latin and extended characters, so most won't see their effective length cut in half. But some writing systems use no Latin characters at all.
For 3-byte characters, the effective password length drops to 24; for 4-byte characters, to 18.
The shortening always happens the same way: silently, with no error and no signal to the user.
What Changes When You Fix bcrypt's 72-Byte Limit?
Adding length validation or migrating to Argon2id is a small code change: a validation rule or a library swap. The behavior change is what matters: passwords stop getting silently truncated, and the pepper stays in the hash for every password.
That closes the exact gap the CVE and the GitLab report above both describe: a password that looks validated but was never fully checked against what the user typed. For a team, that's the difference between an authentication system that passes a security review and one that generates a finding.
Password hashing is one piece of a broader secure-by-design approach to development. If you're setting up authentication from scratch rather than patching an existing system, our guide to Rails authentication and authorization covers where password hashing fits into the bigger picture.
Option 1: Switch to Argon2id
Argon2id has no practical length limit (its input cap is 4 GiB), so the entire byte/character problem this post describes doesn't exist for it. It's memory-hard by design, which makes large-scale GPU cracking far more expensive than against bcrypt.
This is the option for new systems, or for existing ones with enough runway to migrate the hashing library and re-hash passwords over time: it removes a whole class of bugs instead of validating around it, since there's no meaningful length limit left to get wrong.
OWASP's Password Storage Cheat Sheet lists Argon2id as the primary recommendation for new systems. Phoenix's mix phx.gen.auth generator defaults to bcrypt on Unix and pbkdf2 on Windows, but its own docs recommend considering argon2, "the most robust of all 3," for teams whose infrastructure can absorb the cost.
The trade-off is resource cost: Argon2id is deliberately CPU- and memory-intensive, so it needs headroom on your auth servers. Set a reasonable input length limit anyway to block denial-of-service attempts via maliciously long inputs. OWASP's Authentication Cheat Sheet recommends allowing at least 64 characters.
Argon2id having no practical limit doesn't mean an unbounded one is a good idea.
Option 2: Add Length Validation for bcrypt
If you need to stay on bcrypt, reject or truncate passwords over 72 bytes explicitly, at the application layer, instead of letting bcrypt truncate them silently.
This fits existing bcrypt systems where a full hashing-library migration isn't feasible right now, and it closes the gap both the GitLab report and the Spring Security CVE above exposed, turning silent truncation into a validation error the user sees.
In Rails, validate the byte length, not the character count. length: { maximum: 72 } counts characters, so multibyte passwords slip through:
validate :password_within_bcrypt_limit
def password_within_bcrypt_limit
return if password.blank? || password.bytesize <= 72
errors.add(:password, "is too long (maximum is 72 bytes)")
endIf you use has_secure_password on Rails 7.1 or later, this byte check is already built in. Devise's validatable module checks length in characters, so Devise apps need it added.
Note: if you're using a pepper, calculate the maximum safe length as 72 - pepper.bytesize to ensure the pepper is never dropped.
Option 3: Pre-Hashing (Use with Caution)
Hashing the password with a fast function before passing it to bcrypt removes the byte limit without switching algorithms. It's the fix the GitLab report itself proposed for legacy systems that can't touch the bcrypt call directly. OWASP warns, though, that doing it naively is dangerous.
Two specific, documented risks explain why.
First, null bytes: bcrypt treats its input as a null-terminated string, so if H($password)[0] == 0, then bcrypt(H($password)) == bcrypt(""). Base64-encoding the intermediate hash mitigates this.
Second, password shucking: if the inner hash function is used elsewhere unkeyed (e.g., plain SHA-512) and an attacker gets a leaked hash, they can strip the bcrypt layer and attack the weaker inner hash directly, a technique popularized by security researcher Sam Croley's DEF CON talk and explained in a write-up on the technique.
OWASP's safe formula avoids both: bcrypt(base64(hmac-sha384(data: $password, key: $pepper)), $salt, $cost). The HMAC has to be keyed with a pepper stored outside the database. Skip the key, and you're back to the password-shucking risk above.
What Has to Be True Before You Change Your Password Hashing
Integration
Argon2id has mature library support across common stacks: the argon2id gem in Ruby, the argon2 package on npm, argon2-cffi in Python, alexedwards/argon2id in Go, Spring Security's built-in Argon2PasswordEncoder in Java, and native support via password_hash() since PHP 7.3. Confirm your stack's version supports it before committing to a migration timeline.
Compliance and regulatory factor
Weak password storage is the kind of gap a SOC 2 audit or a GDPR data-protection review flags. GDPR's Article 32 requires "appropriate technical and organisational measures" to secure personal data, and a documented CVE against your hashing library is a hard thing to explain away in an audit.
Scalability
Argon2id's memory-hardness is the security feature, but it's also a cost. Tune the work factor against your actual login volume and server headroom, or peak sign-in traffic can become a performance problem.
Institutional trust and change management
Existing bcrypt hashes can't convert directly to Argon2id. A migration re-hashes passwords opportunistically at the next successful login since you never have access to already-hashed plaintext.
Update your security documentation and audit trail to reflect the new algorithm and work factor, and make sure whoever owns the auth code next knows why the validation rule is there – that's what keeps a fix like this from regressing without anyone noticing when the code changes hands.
Key Takeaways
bcrypt silently truncates at 72 bytes. No error is raised, so a long password and its first 72 bytes produce identical hashes.
A major Java framework shipped this bug as a CVE, and GitLab had a HackerOne report on it years earlier.
With a pepper, a long password can push the pepper past the limit and out of the hash. Cap length at 72 -
pepper.bytesize.OWASP now treats bcrypt as legacy. Argon2id is the primary recommendation and scrypt is the fallback.
Naive pre-hashing invites null-byte collisions and password shucking. OWASP's safe form uses a keyed HMAC before bcrypt.
Which Algorithm Should You Use?
bcrypt is widely deployed and has a strong track record, but it silently caps passwords at 72 bytes.
For existing systems using bcrypt, the safest fix is explicit length validation.
For new systems, use Argon2id. If Argon2id isn't available, opt for scrypt.
If you're planning a move from bcrypt to Argon2id, or want a second pair of eyes on an existing authentication setup, our Ruby on Rails team can help.




