Storing passwords is a pretty common workflow that needs to be done when developing web applications, so it's best to use well-established solutions for that.
In Rails, it would be devise — which under the hood uses bcrypt for password hashing. And this is a great choice as bcrypt is a secure — at least at the time of writing — algorithm, and is used very commonly in other technologies. For example, Phoenix on Unix system uses it by default, and I saw it often used in NodeJS projects.
But it has its limitations, and in this post, I will present you one — 72 bytes password length limit. I'll also tell you how to solve this.
What's the Problem with Bcrypt's Security?
So, as mentioned above, most implementations of bcrypt are limited to 72 bytes password length. At first glance, this does not seem so bad — after all, 72 bytes makes for quite a long password. But let's check what will happen if we provide a bit longer password to Ruby implementation of bcrypt.
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
=> true
So our password created what looks like a valid hash, and it is even validated as one. So everything is good, right? Not really. Because if we try to compare that hash to just 72 "a", without the "someextra" part it will still validate:
irb(main)> hash
=> "$2a$12$ijB8LuN33Y6.NoMl3gTLieyd9hYzp7SWD4H6immKiy80DNCTXYgQ."
irb(main)> BCrypt::Password.new(hash) == "a" * 72
=> true
Ok, but as we said before: even without that "someextra" part, as long as the first 72 bytes still create a secure password, it should be fine. But with that, we assume that users create secure passwords, which may not be the case. And if we are using pepper in our setup, a long password can drop it from the hash, reducing its security:
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
=> true
What's more, it is important to point out that the limit is 72 bytes, not 72 characters. On the web, we are mostly using UTF-8 (for passwords as well). That means not all characters are 1 byte in size, and one character can take between 1 to 4 bytes.Quick example? Monterail is a Polish company, and in Poland, we have a couple of extra letters, like "ą", which in UTF-8 takes 2 bytes:
irb(main)> "ą".bytesize
=> 2
This means that if we would use only "ą" to create a password, its effective length is reduced to 36 characters:
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
=> true
To be realistic, in the case of the Polish language, those letters are just something extra on top of the standard Latin alphabet, so in cases of most passwords even if letters like "ą" are used, the effective password length will not be cut in half. But there are alphabets that do not use Latin characters at all and all of them will take more than 1 byte. In extreme cases, the length will be only 24 or maybe even 18 characters, for 3 and 4 bytes accordingly.
And the actual problem here is that it is done silently, no error returned — users don't know that this shortening is happening, even though as shown above, it may silently drop pepper as well.
So How to Beat the 72 Bytes Limitation?
To be fair: this does not make bcrypt insecure — it is a widely used and secure algorithm. It just has a limitation that needs to be taken into consideration when working with it.
There is a great article about password storage on OWASP Cheat Sheet Series, an awesome place to learn more about the problem. And we will use two recommendations from there as solutions to our problem.
One will be to change the algorithm from bcrypt to argon2id, as the password length limit there is 4 GiB, which means: virtually unlimited. So it's important to create some reasonable input limit here, to prevent maliciously long passwords from being provided to create denial attacks.
If argon2 is not an option and you need to stay with bcrypt, the best solution would be to add length validation, to check if a password is not longer than 72 bytes (or even shorter if pepper is used).
Summary
So there you go. The takeaway is this: bcrypt is a secure algorithm but remember that it caps passwords at 72 bytes. You can either check if the passwords are the proper size, or opt to switch to argon2, where you'll have to set a password size limit.
Happy hashing!