Why password hashing matters in production
We've all heard the headlines: another database breach exposes millions of plaintext passwords. The moment you accept a password from a user, you're responsible for protecting it—not just from external attackers, but from your own team members and accidental logs. Password hashing in Go: a practical guide for developers covers the strategies and libraries that let you store credentials safely, enforce constant-time comparisons, and avoid the common pitfalls that sink production systems.
The good news is that Go's standard library and the official cryptography packages make this straightforward. We'll walk through bcrypt for most cases, argon2id for new systems demanding stronger security, and the defensive practices that keep passwords out of logs and memory dumps.
Using bcrypt for password hashing
Bcrypt is the industry standard for password hashing in most production applications. It's simple, battle-tested, and resistant to GPU and ASIC attacks because it's deliberately slow and memory-intensive.
The Go implementation lives in golang.org/x/crypto/bcrypt. Here's how you hash a password when a user signs up:
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func hashPassword(password string) (string, error) {
// Cost of 12 is a good balance; higher is slower but stronger
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return "", err
}
return string(hash), nil
}
func main() {
hash, err := hashPassword("mySecurePassword123")
if err != nil {
fmt.Println("Error hashing:", err)
return
}
fmt.Println("Hashed:", hash)
// Output: $2a$12$... (60 characters)
}The cost parameter (12 in this example) controls how many iterations bcrypt runs. A cost of 12 takes roughly 250ms on modern hardware. When computers get faster, you increment the cost to keep hashing time constant. We typically set it to 12 or 13 for user authentication flows.
When verifying a password during login, use bcrypt.CompareHashAndPassword:
func verifyPassword(hashedPassword, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
return err == nil
}
func main() {
hash, _ := hashPassword("mySecurePassword123")
// Correct password
if verifyPassword(hash, "mySecurePassword123") {
fmt.Println("Login successful")
}
// Wrong password
if !verifyPassword(hash, "wrongPassword") {
fmt.Println("Login failed")
}
}Bcrypt handles the salt internally and encodes it in the hash output, so you don't need to manage salt separately. The hash includes version info, cost, salt, and the hash itself—all in one string you store in your database.
Argon2id for stronger security requirements
For new systems or applications handling extremely sensitive credentials (financial platforms, government systems), argon2id offers superior resistance to attacks. It won the Password Hashing Competition in 2015 and is deliberately more resource-intensive than bcrypt.
Argon2id uses both time cost (iterations) and memory cost, making brute-force attacks exponentially harder. If you're building a trading platform or healthcare app, this is worth the extra complexity.
The Go implementation is in golang.org/x/crypto/argon2:
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"golang.org/x/crypto/argon2"
)
type ArgonParams struct {
Time uint32
Memory uint32
Threads uint8
}
var DefaultParams = ArgonParams{
Time: 2, // 2 iterations
Memory: 64 * 1024, // 64 MB
Threads: 4, // 4 threads
}
func hashPasswordArgon2(password string, params ArgonParams) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey(
[]byte(password),
salt,
params.Time,
params.Memory,
params.Threads,
32, // hash length in bytes
)
// Encode salt and hash in a single string for storage
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, params.Memory, params.Time, params.Threads,
b64Salt, b64Hash), nil
}
func main() {
hash, err := hashPasswordArgon2("mySecurePassword123", DefaultParams)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Argon2 hash:", hash)
}Argon2id is slower—often 500ms to 1+ second per hash—but that slowness is the point. During login spikes, this cost matters, so benchmark before adopting it. For most SaaS platforms, bcrypt is sufficient. For banking or trading systems, argon2id justifies the latency.
Never log passwords
One mistake that haunts teams: a password ends up in logs. A user copies-pastes their password into the support form, or middleware accidentally logs request bodies. Six months later, security audit finds plaintext passwords in rotation backups.
Here's what we do:
- Never log the raw password parameter at any stage.
- Sanitize request bodies in middleware if you log them.
- Use structured logging with explicit fields, not
fmt.Println(r.Body). - Audit log aggregation for sensitive patterns (strings that look like hashes or tokens).
Example of a login handler that avoids logging the password:
func handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&req)
// ✓ DO: Log only the email, never the password
log.Info("Login attempt", "email", req.Email)
// ✗ DON'T: This logs the password
// log.Info("Login request", "body", req)
// Verify against stored hash
user, err := db.GetUserByEmail(req.Email)
if err != nil || !verifyPassword(user.PasswordHash, req.Password) {
log.Info("Login failed", "email", req.Email, "reason", "invalid credentials")
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
log.Info("Login successful", "user_id", user.ID)
w.WriteHeader(http.StatusOK)
}
Even in debug mode, sanitize sensitive fields. Use a custom logger or middleware that strips passwords from structured logs automatically.
Constant-time password comparison
When you compare a user-supplied password against the hash, you must use a constant-time function. Why? A naive string comparison returns false as soon as the first byte differs. An attacker can measure response times and deduce the correct prefix byte-by-byte.
Both bcrypt.CompareHashAndPassword and custom implementations should use crypto/subtle.ConstantTimeCompare:
package main
import (
"crypto/subtle"
"fmt"
)
func main() {
hash1 := "correctHash1234567890abcdefghijk"
hash2 := "correctHash1234567890abcdefghijk"
hash3 := "wrongHash1234567890abcdefghijklm"
// ✓ Constant-time comparison
if subtle.ConstantTimeCompare([]byte(hash1), []byte(hash2)) == 1 {
fmt.Println("Hashes match (safely)")
}
if subtle.ConstantTimeCompare([]byte(hash1), []byte(hash3)) == 1 {
fmt.Println("Hashes match")
} else {
fmt.Println("Hashes differ (safely)")
}
// ✗ NEVER use simple ==
// if hash1 == hash3 { ... } // timing-based attack possible
}Fortunately, bcrypt's built-in CompareHashAndPassword already uses constant-time comparison internally. If you implement custom hash schemes (uncommon), always reach for subtle.ConstantTimeCompare.
Practical schema and storage
Here's how we typically store credentials in a production database:
| Column | Type | Notes |
| id | UUID or bigint | Primary key |
| VARCHAR(255) UNIQUE | Unique index | |
| password_hash | VARCHAR(60) | Bcrypt output is always 60 chars |
| created_at | TIMESTAMP | Account creation date |
| updated_at | TIMESTAMP | Last password change |
For argon2id, use VARCHAR(255) since the encoded output can be longer. Always add an index on email for fast lookups during login.
A minimal user model in Go:
type User struct {
ID string `db:"id"`
Email string `db:"email"`
PasswordHash string `db:"password_hash"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (u *User) SetPassword(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return err
}
u.PasswordHash = string(hash)
u.UpdatedAt = time.Now()
return nil
}
func (u *User) VerifyPassword(password string) bool {
return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil
}
Common mistakes
- Using simple MD5 or SHA1: These are cryptographic hash functions, not password hashes. They're fast (bad for passwords) and have known collisions. Always use bcrypt or argon2id.
- Storing plaintext or reversible encryption: If your database is breached, attackers have usable credentials immediately. Hash is one-way by design.
- Reusing salt: Bcrypt generates random salt per password automatically. Never hardcode or reuse salt; it defeats the purpose.
- Too low a cost: A cost of 4 or 6 hashes in milliseconds. Attackers can try millions per second. Use cost 12+ or argon2id with meaningful memory allocation.
- Logging during password reset flows: Reset tokens and temporary passwords are sensitive too. Apply the same logging discipline.
- Ignoring password updates: Update the
updated_attimestamp when a user changes their password. This helps detect compromised accounts.
Key takeaways
- Use
golang.org/x/crypto/bcryptfor most production systems; cost 12 is a solid default. - Consider argon2id for high-security applications (trading, financial, healthcare) despite the latency cost.
- Never log passwords or request bodies containing passwords; sanitize at the middleware level.
- Always compare hashes with
bcrypt.CompareHashAndPasswordorcrypto/subtle.ConstantTimeCompareto prevent timing attacks. - Store hashes in a dedicated column with sufficient length; bcrypt needs 60 chars, argon2id needs 255.
- Update the password changed timestamp on each reset so you can detect suspicious activity.
- Never use MD5, SHA1, or simple encryption for passwords; one-way hashing with a proper library is non-negotiable.






Comments
Loading comments…