In my previous article, Hashing in Golang with SHA-256, HMAC, and File Checksums, I ended with a warning: never use SHA-256 to store passwords. This article is the follow-up that explains what to use instead, and how to implement it properly in Go.
Storing passwords is one of those tasks where “it works” and “it is safe” are very different things. A login system that hashes passwords with SHA-256 works perfectly in every test you write, and then one database leak later, most of your users’ passwords are cracked over a weekend. The fix is to use a dedicated password hashing algorithm, and Go has excellent support for the two that matter today: bcrypt and argon2id, both maintained by the Go team in the golang.org/x/crypto module.
This tutorial is for backend developers building anything with a login: an API, a web app, an internal admin panel. You will learn why fast hashes fail for passwords, how to hash and verify passwords with bcrypt, how to tune its cost for your hardware, how to build a complete argon2id implementation with proper salt handling and encoding, and which parameters to use in production.
Why SHA-256 Fails and What Bcrypt and Argon2 Do Differently
The problem with SHA-256 for passwords is not weakness, it is speed. SHA-256 is designed to be as fast as possible, and a single modern GPU computes billions of SHA-256 hashes per second. When your database leaks, the attacker does not need to reverse anything. They take a wordlist of common passwords, hash each guess, and compare against your stored hashes. At billions of guesses per second, every human-memorable password falls quickly, salted or not.
Password hashing algorithms attack this math directly with three ideas:
- A work factor. The algorithm is deliberately slow, and you control how slow. Bcrypt has a
costparameter where each increment doubles the work. Turning one guess from nanoseconds into 200 milliseconds cuts an attacker’s rate from billions per second to a handful per second per core. - Memory hardness (argon2). GPUs are fast because thousands of cores run in parallel, but each core has access to very little memory. Argon2 requires a configurable amount of RAM (64 MB in our examples) for every single hash computation, which makes massively parallel GPU cracking impractical. This is the main advantage argon2 has over bcrypt.
- A built-in salt. A salt is a random value generated per password and stored alongside the hash. It guarantees two users with the same password get different hashes, and it makes precomputed lookup tables useless. Both bcrypt and the argon2 convention embed the salt inside the stored string, so you do not manage it separately.
Which one should you pick? Argon2id won the Password Hashing Competition in 2015 and is the current OWASP first choice. Bcrypt is from 1999, still unbroken, and remains a fine choice with one extra caveat you will see later (a 72-byte password limit). In Go there is a practical difference too: the bcrypt package is a complete solution with two functions, while the argon2 package only provides the core algorithm and leaves salt generation, encoding, and verification to you. We will implement both so you can make an informed choice.
Prerequisites
- Ubuntu 20.04, 22.04, or 24.04
- Go 1.21 or newer (
sudo apt install golang-go -yif you do not have it) - Basic Go knowledge: modules, functions, error handling
- Internet access to download the
golang.org/x/cryptomodule
This builds on concepts (digests, salts, constant-time comparison) covered in the previous hashing article, but you can follow along without reading it first.
Step 1: Set Up the Project
Create a module and pull in the crypto packages. Unlike the hashes in the previous article, bcrypt and argon2 are not in the standard library. They live in golang.org/x/crypto, which is maintained by the Go team itself and is the official home for these algorithms:
mkdir ~/pwhash && cd ~/pwhash
go mod init pwhash
go get golang.org/x/crypto/bcrypt golang.org/x/crypto/argon2
go: added golang.org/x/crypto v0.53.0
go: added golang.org/x/sys v0.46.0
Step 2: Hash and Verify with Bcrypt
Bcrypt is the easy one, so we start there. The entire API you need is two functions:
mkdir bcryptdemo
nano bcryptdemo/main.go
package main
import (
"fmt"
"log"
"golang.org/x/crypto/bcrypt"
)
func main() {
password := []byte("correct horse battery staple")
hash, err := bcrypt.GenerateFromPassword(password, 12)
if err != nil {
log.Fatal(err)
}
fmt.Println("stored hash:", string(hash))
err = bcrypt.CompareHashAndPassword(hash, password)
fmt.Println("correct password:", err == nil)
err = bcrypt.CompareHashAndPassword(hash, []byte("wrong password"))
fmt.Println("wrong password:", err == nil)
}
Run it:
go run ./bcryptdemo
stored hash: $2a$12$A2SJ9ZtvM.mygG.PWKevGeq.sS8mW0rg4YCXToAORDZjqz7RGaGdO
correct password: true
wrong password: false
That stored hash string deserves a closer look, because it explains why you never store a salt column in your database. The $ separators split it into parts: 2a is the bcrypt version, 12 is the cost we chose, and the long tail contains the randomly generated salt followed by the actual hash. Everything needed for verification travels in one string, so your users table needs exactly one password_hash column.
This self-describing format is also why there is no “hash the login attempt and compare strings” step. CompareHashAndPassword parses the stored string, extracts the salt and cost, recomputes the hash from the candidate password, and compares in constant time. If you run the program again, you will get a completely different stored hash (a new random salt), and both would still verify correctly.
Note the verification result comes back as an error, not a boolean. A wrong password returns bcrypt.ErrMismatchedHashAndPassword, and anything else (a corrupted hash, an invalid format) returns a different error. In a real login handler, treat mismatch as “wrong credentials” and any other error as a server-side problem worth logging.
Step 3: Tune the Bcrypt Cost for Your Hardware
Cost 12 was not a random pick. The right cost is hardware-dependent: too low and you give attackers an easy time, too high and your login endpoint burns CPU and users wait. The usual target is around 200 to 500 milliseconds per hash on your production hardware. Measure it:
mkdir bcryptcost
nano bcryptcost/main.go
package main
import (
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
)
func main() {
password := []byte("correct horse battery staple")
for cost := 10; cost <= 14; cost++ {
start := time.Now()
bcrypt.GenerateFromPassword(password, cost)
fmt.Printf("cost %d: %v\n", cost, time.Since(start).Round(time.Millisecond))
}
}
go run ./bcryptcost
cost 10: 42ms
cost 11: 84ms
cost 12: 174ms
cost 13: 346ms
cost 14: 695ms
You can see the doubling clearly: each cost increment doubles the time, because cost is the exponent of the number of internal iterations. On this machine, cost 12 or 13 lands in the sweet spot. OWASP’s minimum recommendation is cost 10, and the package default (bcrypt.DefaultCost) is also 10, so treat that as the floor, not the target.
One more thing this enables: because the cost is stored inside each hash, you can raise the cost later and old hashes still verify. A common pattern is to check the stored cost at login with bcrypt.Cost(hash) and transparently re-hash with the new cost when a user logs in successfully, since login is the only moment you have the plaintext password.
Step 4: Hash and Verify with Argon2id
Now the stronger but more manual option. The golang.org/x/crypto/argon2 package gives you one core function, argon2.IDKey, which turns a password, a salt, and parameters into a raw key. Everything else (generating the salt, packaging the result, verifying) is your job. The community convention is to store the result in the PHC string format, which looks like this:
$argon2id$v=19$m=65536,t=3,p=2$<base64 salt>$<base64 hash>
Just like bcrypt’s format, it is self-describing: the parameters and salt travel with the hash, so you can change parameters later without breaking old hashes. Here is a complete, production-shaped implementation:
mkdir argon2demo
nano argon2demo/main.go
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"log"
"strings"
"golang.org/x/crypto/argon2"
)
type params struct {
memory uint32
time uint32
threads uint8
saltLen uint32
keyLen uint32
}
var defaultParams = params{
memory: 64 * 1024,
time: 3,
threads: 2,
saltLen: 16,
keyLen: 32,
}
func hashPassword(password string, p params) (string, error) {
salt := make([]byte, p.saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(password), salt, p.time, p.memory, p.threads, p.keyLen)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Key := base64.RawStdEncoding.EncodeToString(key)
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, p.memory, p.time, p.threads, b64Salt, b64Key)
return encoded, nil
}
func verifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("invalid hash format")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, err
}
if version != argon2.Version {
return false, errors.New("incompatible argon2 version")
}
var p params
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.time, &p.threads); err != nil {
return false, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, err
}
key, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, err
}
candidate := argon2.IDKey([]byte(password), salt, p.time, p.memory, p.threads, uint32(len(key)))
return subtle.ConstantTimeCompare(key, candidate) == 1, nil
}
func main() {
password := "correct horse battery staple"
encoded, err := hashPassword(password, defaultParams)
if err != nil {
log.Fatal(err)
}
fmt.Println("stored hash:", encoded)
ok, _ := verifyPassword(password, encoded)
fmt.Println("correct password:", ok)
ok, _ = verifyPassword("wrong password", encoded)
fmt.Println("wrong password:", ok)
}
Run it:
go run ./argon2demo
stored hash: $argon2id$v=19$m=65536,t=3,p=2$8wt8ExCSzsZnZG4f6Ykh4w$OJC9D6Yvk+nSQAtUJwCM/uZqnqDgiLm1ZG2WUyl2cGc
correct password: true
wrong password: false
Walking through the important decisions in this code:
- Parameters.
m=65536means 64 MB of memory per hash,t=3is the number of passes over that memory, andp=2is the parallelism. These are in line with OWASP’s recommended configurations. Memory is the security dial that hurts GPU attackers most, so if you tune anything, tune memory up as far as your server can afford. - The salt is 16 random bytes from
crypto/rand, the operating system’s cryptographic random source. Never usemath/randfor salts. - Verification recomputes, then compares with
subtle.ConstantTimeCompare. This is the same timing-attack defense ashmac.Equalfrom the previous article. A plain==on strings returns early at the first difference, and that timing difference is measurable. base64.RawStdEncodingis standard base64 without=padding, which is what the PHC format specifies. Using the padded encoder is a common source of hashes that other libraries refuse to parse.
Note that verification takes roughly the same time as hashing (about 100 to 200 ms with these parameters), because it recomputes the full function. That is the entire point, but plan for it: every login consumes 64 MB of RAM for a fraction of a second, so a burst of 100 concurrent logins needs about 6.4 GB. If your server is small, lower m and raise t to compensate.
Common Mistakes and Troubleshooting
“bcrypt: password length exceeds 72 bytes”. Bcrypt only processes the first 72 bytes of input, and the Go package refuses longer passwords outright rather than silently truncating them. If users can enter long passphrases, either enforce the limit at registration or use argon2id, which has no such limit. Do not “fix” this by pre-hashing the password with SHA-256 before bcrypt unless you fully understand the pitfalls (raw digest bytes can contain NUL bytes that bcrypt implementations treat inconsistently).
Comparing hashes yourself. If you find yourself hashing the login attempt and doing storedHash == newHash, stop. With bcrypt that is plain wrong (each hash has a new random salt, so the strings never match). With argon2, extract the salt and compare with subtle.ConstantTimeCompare as shown above.
Using argon2.Key instead of argon2.IDKey. The package exports both. Key implements argon2i, an older variant that is weaker against GPU attacks. You want IDKey (argon2id), which is the hybrid variant everyone recommends today. It is a one-character bug that silently downgrades your security.
A global salt, or no salt. Reusing one salt for all users lets an attacker crack every account with a single pass over their wordlist. The salt must be random per password. With bcrypt this cannot go wrong (the library does it for you), which honestly is a good argument for bcrypt in teams where crypto code gets copy-pasted.
Returning different errors for “user not found” and “wrong password”. Not a hashing bug, but it leaks which emails have accounts. Run the password check against a dummy hash even when the user does not exist, and return the same generic error either way.
Login suddenly slow after deploying to a small VPS. Argon2 with m=65536 on a 512 MB instance will grind and possibly OOM under concurrent logins. Benchmark on the actual production hardware, not your workstation, and size m to your worst-case concurrent login count.
Best Practices
- New project: use argon2id with at least the parameters shown here (64 MB, t=3, p=2), or OWASP’s lighter alternative (about 19 MB, t=2, p=1) if memory is tight. Already on bcrypt with cost 10 or higher: you are fine, no urgent migration needed.
- Target 200 to 500 ms per hash on production hardware and re-benchmark every couple of years. Hardware gets faster, and your work factor should follow.
- Store the full encoded string in a single column (
VARCHAR(255)is plenty) and let the self-describing format handle parameter upgrades. Re-hash with new parameters on successful login. - Rate-limit your login endpoint. Password hashing protects leaked databases, not live endpoints, and it actually makes online brute force a cheap denial-of-service vector since every attempt costs you CPU. Combine application-level rate limiting with server-level protection like Fail2ban.
- Never log passwords or hashes, and keep the plaintext in memory only as long as needed. The hash is a secret too: it is exactly what offline attackers need to start cracking.
- Do not invent your own scheme. No SHA-256 with extra rounds, no custom salting tricks. Bcrypt and argon2id are studied by people who break these things for a living. Your job is choosing parameters, not designing algorithms.
Conclusion
You now have working, verified password hashing in Go both ways: bcrypt with its two-function API and self-tuning via the cost parameter, and argon2id with a complete PHC-format implementation including salt generation, encoding, parsing, and constant-time verification. You also measured why the work factor matters, watching bcrypt double from 42 ms to 695 ms across five cost levels.
If you take one thing away: the code in Step 4 (or the five lines of bcrypt in Step 2) is all your login system needs, and anything faster than these algorithms is the wrong tool for passwords. For the other side of hashing (integrity checks, HMAC signatures, sharding), see the companion article Hashing in Golang with SHA-256, HMAC, and File Checksums. Natural next steps from here are adding rate limiting to your login endpoint and issuing session tokens or JWTs after a successful verification.