The login form trap
You build a login form. A user types their password. You save it to the database. If you save the raw string, you've just handed the keys to the kingdom to anyone who reads the database file. Hashing fixes this. You transform the password into a fixed-length string that looks like random noise. You can check if a new password matches the hash, but you can't reverse the hash to get the password back.
Argon2 is the current gold standard for this job. It won the Password Hashing Competition and is designed to be slow and memory-hungry. This makes it expensive for attackers to brute-force while remaining fast enough for a single login attempt. The golang.org/x/crypto/argon2 package provides the implementation.
Argon2: The memory-hard blender
Think of password hashing like a one-way blender. You throw ingredients in, blend them up, and get a smoothie. You can't un-blend the smoothie to get the exact strawberries and milk back. Argon2 adds a twist. It's a blender that also requires a heavy motor and a lot of electricity.
If an attacker wants to try a million passwords, they have to run that heavy motor a million times. The algorithm allocates a large chunk of memory for each attempt. GPUs and ASICs can run billions of operations per second, but they have limited memory. Argon2 forces each attempt to consume megabytes of RAM, which bottlenecks the attacker's hardware.
The "salt" is like adding a random spice to the mix. Two people with the same password get different hashes because their salts differ. This stops attackers from using pre-computed rainbow tables. You generate a unique salt for every password and store it alongside the hash.
Argon2 has three variants. Argon2i is resistant to side-channel attacks. Argon2d is resistant to GPU cracking. Argon2id combines both. The argon2.Key function in the standard crypto extension implements Argon2id by default. This is the safe choice for almost every application.
Minimal hashing and verification
Here's the core logic: generate a salt, hash the password, and store both. Verification regenerates the hash and compares it safely.
import (
"crypto/rand"
"encoding/hex"
"golang.org/x/crypto/argon2"
)
// HashPassword creates a salted hash and returns a hex string containing both.
func HashPassword(password []byte) (string, error) {
// 16 bytes gives 128 bits of entropy, enough to prevent rainbow table attacks.
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
// Argon2 parameters: iterations, memory in KB, parallelism, output length.
// 1 iteration, 64MB memory, 4 threads, 32-byte key.
hash := argon2.Key(password, salt, 1, 64*1024, 4, 32)
// Concatenate salt and hash; hex encoding makes it safe for JSON or DB storage.
return hex.EncodeToString(append(salt, hash...)), nil
}
Verification needs to extract the salt and compare the result without leaking timing information.
import "golang.org/x/crypto/subtle"
// VerifyPassword regenerates the hash and compares it safely.
func VerifyPassword(password []byte, storedHash string) bool {
parsed, err := hex.DecodeString(storedHash)
if err != nil || len(parsed) < 16 {
return false
}
// Extract the original salt from the beginning of the stored data.
salt := parsed[:16]
storedHashBytes := parsed[16:]
// Recompute hash using the same salt and parameters.
newHash := argon2.Key(password, salt, 1, 64*1024, 4, 32)
// Constant-time comparison avoids leaking info via execution time.
return subtle.ConstantTimeCompare(newHash, storedHashBytes) == 1
}
Salt goes in the hash. Keep the schema simple.
What happens under the hood
When you call HashPassword, the program asks the operating system for random bytes via rand.Read. This blocks until enough entropy is available. The result is a 16-byte salt. The argon2.Key function then runs the algorithm. It allocates 64MB of memory and performs the mixing operations. This takes a few hundred milliseconds. The result is a byte slice. You append the salt to the front and encode to hex. The hex string is what goes into your database.
Verification decodes the hex. It slices the salt. It runs argon2.Key again. If the password is wrong, the hash differs. subtle.ConstantTimeCompare checks byte-by-byte without short-circuiting. If you used bytes.Equal, the comparison would stop at the first mismatch. An attacker could measure the time difference to guess the hash byte-by-byte. ConstantTimeCompare always takes the same time regardless of where the mismatch occurs.
Constant-time comparison is the only way. Timing leaks are real.
Realistic service layer
Real code usually wraps this in a struct with configuration. Go convention favors explicit error handling and short receiver names.
// AuthConfig holds tuning parameters for the hashing algorithm.
// Adjust these values based on your server's memory and desired login latency.
type AuthConfig struct {
Time uint32
Memory uint32
Threads uint8
KeyLength uint32
}
// DefaultConfig targets ~100ms latency on modern hardware.
var DefaultConfig = AuthConfig{
Time: 1,
Memory: 64 * 1024,
Threads: 4,
KeyLength: 32,
}
The hashing function accepts the config and returns errors explicitly.
import (
"fmt"
)
// HashPasswordWithConfig creates a hash using explicit parameters.
func HashPasswordWithConfig(password []byte, cfg AuthConfig) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("crypto/rand failed: %w", err)
}
hash := argon2.Key(password, salt, cfg.Time, cfg.Memory, cfg.Threads, cfg.KeyLength)
return hex.EncodeToString(append(salt, hash...)), nil
}
Run gofmt. It formats the code consistently so you can focus on the logic. Receiver names like cfg match the type. Error wrapping with %w preserves the error chain. Context is plumbing. Run it through every long-lived call site. Hashing is a short CPU burst. You usually don't pass context to the hash function itself, though the HTTP handler calling it will have one.
Pitfalls and tuning
The biggest risk is memory exhaustion. The memory cost parameter is in kilobytes. 64 * 1024 means 64MB per hash. If you get a spike of 1000 concurrent logins, the server needs 64GB of RAM just for hashing. If your server has 16GB, you crash. You must tune the memory cost based on your maximum expected concurrency.
Benchmark the hash function on your production hardware. Run a loop of 10 hashes. Measure the time. Adjust the memory parameter until the latency hits your target, usually 100 to 200 milliseconds. Lower memory cost if you expect high traffic. Higher memory cost if you have plenty of RAM and low traffic.
If you swap the order of arguments to argon2.Key, the compiler rejects the program with cannot use password (variable of type []byte) as uint32 value in argument. The signature is strict. The function expects password, salt, time, memory, threads, keyLength. Pass typed constants or cast integers to avoid this.
Don't store the salt in a separate database column. Prepend it to the hash as shown in the examples. This keeps the storage format self-contained. If you move the data, the salt moves with it.
Don't use a global salt. Every password needs a unique salt. rand.Read generates a fresh salt every time. Reusing salts defeats the purpose and allows rainbow table attacks.
Argon2 is memory-hard. Tune the cost or crash the server.
Decision matrix
Use Argon2id when you need the strongest defense against GPU and ASIC attacks for password storage. Use bcrypt when you are maintaining legacy systems or have strict compatibility requirements with older frameworks. Use SHA-256 with a salt only for non-password data like API keys where speed matters more than brute-force resistance. Use plain text never.