🥩 Analogy
Hashing is a meat grinder: you can turn a steak into mince, but never mince back into a steak. That one-way-ness is the whole point for passwords — you store the mince, and to check a login you grind the attempt and compare. Encryption, by contrast, is a safe: a key locks and unlocks it. Encoding (base64) is just rewriting the recipe in a different alphabet — anyone can read it back. Mixing these up (“I base64’d it, so it’s safe”) is the most common crypto mistake there is.
Three operations people confuse
graph TD H["Hashing<br/>SHA-256, bcrypt"] --> H1["one-way · no key<br/>verify by re-hashing"] E["Encryption<br/>AES-GCM"] --> E1["two-way · needs a key<br/>recover the plaintext"] C["Encoding<br/>base64, hex"] --> C1["reversible · no key<br/>NOT security"]
- Hashing — one-way, no key. Used for integrity (file checksums) and password storage. You can’t reverse it; you re-hash and compare.
- Encryption — two-way, with a key. Used to keep data confidential and recover it later. (symmetric encryption covers this.)
- Encoding — reversible, no key, zero security. base64/hex just re-represent bytes for transport.
Cryptographic hashes (and the broken ones)
A good hash is deterministic, fast to compute, and collision/preimage resistant. The stdlib crypto/sha256 is the workhorse for integrity:
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
func main() {
data := []byte("the quick brown fox")
sum := sha256.Sum256(data)
fmt.Println("sha256:", hex.EncodeToString(sum[:]))
// Deterministic: same input -> same digest, every time.
sum2 := sha256.Sum256(data)
fmt.Println("stable:", sum == sum2)
// Avalanche: one changed byte -> a completely different digest.
other := sha256.Sum256([]byte("the quick brown fox"))
fmt.Println("differs:", sum != other)
}
MD5 and SHA-1 are broken — practical collisions exist; never use them for security. SHA-256/SHA-512 (and SHA-3, now in the stdlib) are fine for integrity and signatures. But for passwords, even SHA-256 is the wrong tool — because it’s fast.
Why passwords need a SLOW hash
A modern GPU computes billions of SHA-256 hashes per second. So a stolen database of SHA-256 passwords — even salted — falls quickly to brute force. Passwords need a hash that’s deliberately slow and memory-hard, with a tunable cost.
First, the salt: a unique random value per password so identical passwords don’t collide and rainbow tables don’t work. This runnable demo shows the problem and the fix (fixed salts here for reproducibility — real salts come from crypto/rand):
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
func hashed(pw, salt string) string {
sum := sha256.Sum256([]byte(salt + pw))
return hex.EncodeToString(sum[:])[:16]
}
func main() {
// Two users, SAME password.
fmt.Println("no salt, user A:", hashed("hunter2", ""))
fmt.Println("no salt, user B:", hashed("hunter2", ""))
fmt.Println("-> identical hashes leak that the passwords match\n")
// With a unique salt each, the hashes differ.
fmt.Println("salted, user A:", hashed("hunter2", "saltA"))
fmt.Println("salted, user B:", hashed("hunter2", "saltB"))
fmt.Println("-> identical passwords now hash differently")
}
Salting fixes duplicates and rainbow tables — but it does not fix speed. For that you need a real password KDF.
The right way: argon2id or bcrypt
Use a purpose-built, slow, salted KDF. These live in golang.org/x/crypto (fenced — they run on the playground but aren’t stdlib). argon2id is the modern recommendation; bcrypt is the battle-tested classic:
import "golang.org/x/crypto/bcrypt"
// Hashing at registration. The cost (here 12) makes each hash deliberately slow.
hash, _ := bcrypt.GenerateFromPassword([]byte(password), 12)
// store `hash` — it embeds the algorithm, cost, and a random salt.
// Verifying at login — constant-time, re-derives from the embedded salt/cost.
err := bcrypt.CompareHashAndPassword(hash, []byte(attempt))
if err == nil {
// password matches
}
import "golang.org/x/crypto/argon2"
// argon2id: memory-hard, the current best practice. Salt from crypto/rand.
key := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
// store: algorithm params + salt + key, and compare with subtle.ConstantTimeCompare
bcrypt.GenerateFromPassword generates the salt, runs the slow Blowfish-based KDF, and packs everything into one string you store. CompareHashAndPassword does verification in constant time. You tune the cost so a login takes ~100ms — trivial for one user, ruinous for an attacker guessing billions.
🐹 The password-storage checklist
Never store plaintext, encrypt (it’s recoverable), or fast-hash passwords. Do use argon2id or bcrypt, which salt and slow automatically. Tune the cost to ~100ms per hash and revisit it as hardware improves. Verify with the library’s constant-time compare, never ==. And add a global pepper (a secret key mixed in, stored separately from the DB) for defense in depth. Note: Go 1.24 brought pbkdf2, hkdf, and sha3 into the standard library, but for passwords specifically, argon2id/bcrypt remain the right choice.
⚠️ bcrypt silently truncates at 72 bytes
bcrypt only considers the first 72 bytes of the input — anything longer is ignored, so two different long passwords (or long passphrases sharing a 72-byte prefix) can validate against the same hash. The common fix is to pre-hash the password with SHA-256 and base64-encode it before passing it to bcrypt, or to use argon2id, which has no such limit. Also never cap user password length low to “fit” — let users pick long passphrases and handle the length yourself.
See also
- Symmetric encryption — the two-way counterpart, done with AEAD.
- Attacking weak crypto — cracking fast/unsalted hashes, and why slow KDFs win.
- Authentication & authorization — where password verification fits.
- Why Go for security — the crypto/subtle constant-time primitives.
Next: keeping data confidential with two-way crypto — symmetric encryption.
Related topics
Keeping data confidential with one shared key — why you reach for authenticated encryption (AES-GCM), the absolute rule of nonce uniqueness, and the broken modes (ECB) you must never use.
cryptographyAttacking Weak CryptoHow good algorithms get broken by bad usage — dictionary attacks on fast hashes, ECB pattern leakage, nonce reuse, hardcoded keys, weak randomness, and timing side-channels — and how to avoid each.
defenseAuthentication & AuthorizationProving who you are and deciding what you may do — sessions vs tokens, secure token generation and constant-time checks, password verification, and least-privilege authorization (RBAC).
Check your understanding
Score: 0 / 51. What's the difference between hashing, encryption, and encoding?
A hash maps data to a fixed-size digest with no way back (you can only re-hash and compare). Encryption transforms data so a key can recover it. Encoding (base64, hex) just re-represents bytes — anyone can decode it. Confusing encoding for security ('I base64'd the password') is a classic, dangerous mistake.
2. Why must you NOT store passwords with a fast hash like SHA-256, even salted?
SHA-256 is designed to be fast — great for integrity, terrible for passwords. A GPU computes billions of SHA-256 hashes per second, so a leaked table of salted SHA-256 passwords is cracked quickly. Password hashing needs a slow, memory-hard KDF (bcrypt, scrypt, argon2id) whose cost you tune so each guess is expensive.
3. What does a salt accomplish?
Without a salt, two users with password 'hunter2' get the same hash, and attackers use precomputed rainbow tables. A unique per-user salt (stored alongside the hash) makes every hash unique and forces the attacker to crack each one individually. Modern KDFs like bcrypt generate and embed the salt for you.
4. Which is the right default for hashing passwords in Go today?
argon2id is the current recommendation (memory-hard, resistant to GPU/ASIC cracking); bcrypt remains a solid, widely-supported choice. Both salt automatically and let you tune cost. MD5 and SHA-1 are cryptographically broken; SHA-256 is too fast. Never roll your own — use x/crypto/argon2 or x/crypto/bcrypt.
5. How should you VERIFY a password against a stored bcrypt/argon2 hash?
The KDF library handles verification: it extracts the salt and cost from the stored hash, re-derives from the candidate password, and compares in constant time. You never compare hashes with == (timing leak) and never 'decrypt' — these are one-way. CompareHashAndPassword returns nil on a match.
Comments
Sign in with GitHub to join the discussion.