43.5 Password Hashing with bcrypt: golang.org/x/crypto/bcrypt
Right, let’s talk about password hashing. This is one of those things where if you get it wrong, you’re the person on the Hacker News post everyone clowns on. We don’t want that. You’re storing a secret the user entrusted to you, not a plaintext monument to your own laziness. So we’re going to do it properly, and in Go, that means reaching for golang.org/x/crypto/bcrypt. It’s the community’s battle-tested choice, and for good reason.
First, the cardinal rule, which I know you know but I’m saying it anyway: you never, ever store a password in plaintext. You hash it. Hashing is a one-way function. You put the password in, you get a fixed-length jumble of bytes out. The key is that it’s computationally infeasible to reverse the process. bcrypt is specifically designed for passwords. It’s slow (intentionally!), it uses a salt (by default!), and it’s a pain to brute force. It’s the digital equivalent of setting up a bunch of annoying, time-consuming booby traps for an attacker.
Generating the Hash: GenerateFromPassword
Let’s turn a password into something you can safely store in your database. The function you want is bcrypt.GenerateFromPassword. It does the heavy lifting.
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
// In reality, this comes from your user's registration form.
password := []byte("supersecretpassword123")
// Cost is the key knob to turn. More on this in a second.
cost := bcrypt.DefaultCost // This is 10 as of Go 1.20. A sane default.
hashedBytes, err := bcrypt.GenerateFromPassword(password, cost)
if err != nil {
panic(err) // Handle this better in production, obviously.
}
// This is the string you store in your database.
hashedString := string(hashedBytes)
fmt.Println("Hashed password:", hashedString)
// Output looks like: $2a$10$zaY2nGXRSzQeDqdsYRWEU.Ovl0m6hrlizaHi6hL3pHM6sQX6WzJjC
}
See that output? It’s not just random bytes. It’s a self-contained format. It tells everyone “I’m a bcrypt hash ($2a$), my cost factor is 10 ($10$), and here’s my salt and the actual hash.” Everything you need to verify a later login attempt is right there. Elegant.
The All-Important Cost Factor
That cost parameter is everything. It determines the number of hashing rounds, and it scales exponentially. A cost of 10 might take ~100ms to compute on your laptop. A cost of 11 takes ~200ms. A cost of 12 takes ~400ms. This is the feature that makes bcrypt resilient.
You want this to be as high as you can possibly tolerate on your specific hardware without making your login requests painfully slow. On a modern web server, a cost of 12 or even 13 is often reasonable. You must benchmark this. Don’t just copy-paste DefaultCost from a tutorial written five years ago. Run a quick benchmark on your production-grade hardware to see what the latency impact is.
func benchmarkCost(cost int) {
start := time.Now()
bcrypt.GenerateFromPassword([]byte("my password"), cost)
duration := time.Since(start)
fmt.Printf("Cost %d: %v\n", cost, duration)
}
// Run this against different costs to find your sweet spot.
Comparing Passwords: CompareHashAndPassword
When a user logs in, you don’t “decrypt” the hash. That’s impossible. Instead, you take the password they just typed, hash it using the exact same parameters (which are conveniently stored in the original hash string), and see if the two hashes match. This is what CompareHashAndPassword does for you.
func main() {
// The string you fetched from the database.
storedHash := "$2a$10$zaY2nGXRSzQeDqdsYRWEU.Ovl0m6hrlizaHi6hL3pHM6sQX6WzJjC"
// The password from the user's login attempt.
loginPassword := []byte("supersecretpassword123")
// This is the moment of truth.
err := bcrypt.CompareHashAndPassword([]byte(storedHash), loginPassword)
if err != nil {
// Passwords don't match. Log the attempt, tell the user "nope".
fmt.Println("Authentication failed:", err)
return
}
// Success! The user is who they say they are.
fmt.Println("Authentication successful!")
}
The brilliance here is that the function handles everything: parsing the stored hash to find the cost and salt, applying those to the new password, and doing a constant-time comparison. Which brings me to my next point…
The Constant-Time Comparison Thing
This is a subtle but critical detail. A naive byte-by-byte comparison would stop at the first mismatched byte. This is bad because an attacker could theoretically measure tiny timing differences to slowly figure out the correct hash. bcrypt.CompareHashAndPassword uses a constant-time comparison under the hood. It takes the same amount of time to compare two hashes whether they’re identical or completely different. It closes that potential timing side-channel. You get this for free, which is fantastic.
Common Pitfalls and Sharp Edges
- Upgrading the Cost: What happens when you decide your cost of 10 isn’t good enough anymore? You can’t just change it for existing users. The next time a user successfully logs in, you have a plaintext password (for a brief moment). That’s your chance to re-hash it with the new, higher cost and update their database record.
- Password Length: BCrypt has a de facto limit of 50-72 bytes. Passwords longer than this are truncated. Yes, you read that right. It’s the one genuinely weird design choice. If a user enters a 100-character password, only the first 72 characters matter for the hash. This is almost never a practical issue (if your users are writing essays for passwords, you have a different kind of problem), but you should know it’s there. It’s a historical artifact of the algorithm’s design.
- The
[]byteDance: The library works on byte slices, not strings. This is mildly annoying but forces you to think about wiping the sensitive data from memory. You can (and should) zero out the[]byteslice containing the plaintext password when you’re done with it, which you can’t do with a string (as they are immutable). It’s a small security-conscious design.