Go never shipped a standard-library password hashing function, which is why two external packages — golang.org/x/crypto/bcrypt and golang.org/x/crypto/argon2 — carry nearly all production password storage in the Go ecosystem. They behave very differently under the hood. bcrypt, built on a Blowfish-derived cipher from 1999, enforces a hard 72-byte input limit; unlike some other language bindings that silently truncate anything past byte 72, Go's implementation returns bcrypt.ErrPasswordTooLong instead — an error a surprising number of handlers never check. Argon2, standardized in RFC 9106 and winner of the 2015 Password Hashing Competition, ships in Go only as a raw key-derivation primitive: argon2.IDKey() computes a hash but leaves salt generation, output encoding, and constant-time comparison entirely to the caller. OWASP's Password Storage Cheat Sheet currently recommends Argon2id as the default choice, with bcrypt as an acceptable legacy option when Argon2id isn't available. This piece walks through how to pick parameters for both, and the specific ways Go developers get them wrong.
Why does bcrypt's 72-byte limit matter in Go specifically?
It matters because Go fails loudly where other implementations fail silently, which changes what "correct" error handling looks like. The original C bcrypt implementation — and several bindings in other languages — simply discards any password bytes beyond the 72nd before hashing, so "correct-horse-battery-staple-padding-padding-padding..." and a version with one extra trailing character can hash identically. Go's x/crypto/bcrypt instead returns bcrypt.ErrPasswordTooLong from bcrypt.GenerateFromPassword() when the input exceeds 72 bytes, refusing to hash at all. That is safer behavior, but only if the caller checks the error. Code that does hash, _ := bcrypt.GenerateFromPassword(pw, cost) and ignores the error will store an empty or malformed hash, and every long password becomes either unrecoverable or, in some sloppy retry paths, silently downgraded to a shorter effective secret. The fix is a pre-check: enforce a maximum password length (commonly 64–72 bytes) at the input-validation layer, before the password ever reaches bcrypt, rather than discovering the 72-byte wall only after a support ticket about "randomly failing" signups.
What bcrypt cost factor should you actually use?
OWASP's current guidance sets a floor of work factor 10 for bcrypt, with the cost capped at 31 in the algorithm's design (each increment doubles the number of rounds, so cost is logarithmic, not linear). In Go, bcrypt.GenerateFromPassword(password, cost) takes that cost directly, and bcrypt.DefaultCost in the package is set to 10 — a reasonable floor, not a ceiling to leave unexamined. Because the scaling is exponential, moving from cost 10 to cost 12 roughly quadruples hashing time, not doubles it twice; teams should benchmark on their actual production hardware and pick the highest cost that keeps login-path latency acceptable, typically targeting somewhere in the 200–500ms range per hash on modern server CPUs. The common Go mistake here is hardcoding a cost constant once at initial implementation and never revisiting it — bcrypt has no built-in way to detect "this hash was made with an outdated cost," so an application needs its own rehash-on-successful-login logic that compares the cost embedded in the stored hash (bcrypt encodes it in the hash string itself, e.g. $2a$10$...) against the current target and re-hashes when they diverge.
What are the correct Argon2id parameters, and why does Go make them easy to get wrong?
OWASP's Password Storage Cheat Sheet lists two current Argon2id profiles: a minimum baseline of memory=19 MiB, iterations=2, parallelism=1, and a higher-security profile of memory=46 MiB, iterations=1, parallelism=1, tuned to land around 100–200ms per hash. In Go, those map directly to the four numeric arguments of argon2.IDKey(password, salt, time, memory, threads, keyLen) — but the function signature is the entire API surface. There is no argon2.GenerateFromPassword and no argon2.CompareHashAndPassword equivalent to bcrypt's helpers. Every team using Argon2id in Go has to write its own salt generation (crypto/rand, never math/rand), its own PHC-style string encoding to store algorithm version, memory, time, parallelism, salt, and hash together, and its own parser to reconstruct those parameters on login. Because Go doesn't force this structure on you, it's common to find code that stores only the raw argon2.IDKey output with parameters hardcoded as constants elsewhere in the codebase — which works until someone changes the memory parameter and every existing hash in the database becomes unverifiable, because the parameters used to produce them were never persisted alongside the hash.
What's the most common comparison bug in hand-rolled Argon2 code?
It's comparing the computed hash to the stored hash with == or bytes.Equal() instead of a constant-time comparison. Both of those comparison paths on typical implementations exit as soon as they find the first differing byte, which means the time the comparison takes can leak how many leading bytes matched — a timing side channel that, with enough repeated attempts over a network, has historically been used to reconstruct secrets byte-by-byte. Go's standard library ships the fix already: subtle.ConstantTimeCompare(a, b) from crypto/subtle runs in time independent of where the two slices first diverge, and it's a drop-in replacement for bytes.Equal in a login comparison. bcrypt users get this for free because bcrypt.CompareHashAndPassword() handles it internally; Argon2id users writing their own verification function have to remember to call subtle.ConstantTimeCompare themselves, and it's an easy line to skip when the code otherwise "just works" in local testing.
How do you decide between bcrypt and Argon2id for a new Go service?
For a greenfield Go service, OWASP's current cheat sheet treats Argon2id as the preferred default, with bcrypt as an acceptable fallback where an Argon2id implementation isn't practical — for example, in constrained environments where the memory-hard cost of Argon2id (which is the point: it resists GPU and ASIC cracking far better than bcrypt's CPU-bound design) isn't tolerable. If a service already has years of bcrypt hashes in production, migrating wholesale isn't necessary; the standard pattern is verifying existing users against bcrypt as before, then rehashing with Argon2id on their next successful login, so the hash population migrates gradually without forcing a password reset. What should not happen in either case is picking parameters once, at initial launch, and treating them as permanent — hardware gets faster, and a cost factor or memory parameter that was reasonable at launch quietly becomes inadequate a few years later if nobody revisits it.