How to Hash Passwords in Go with argon2

Web
Hash passwords in Go using the golang.org/x/crypto/argon2 package with Argon2id for secure storage.

How to Hash Passwords in Go with argon2

A user signs up for your app and types "password123". You save that string to the database. Six months later, a misconfigured S3 bucket exposes the entire database dump. Now a script kiddie has every username and every password. Your users change their email passwords immediately because they reuse credentials everywhere. The fix isn't better bucket permissions. The fix is making sure the database never holds the actual password in the first place.

The one-way street

Hashing turns data into a fixed-size fingerprint. You can't reverse it. If you hash "password123", you get a blob of bytes. If you hash "password123" again, you get the same blob. If you hash "password124", you get a totally different blob. To check a login, you hash the input and compare the result.

Think of a hash function like a meat grinder. You put in a steak, you get ground beef. You can't un-grind the beef to get the steak back. But if you grind the same steak again, you get the same ground beef. Password hashing adds a twist: you mix in a random spice called a salt before grinding. The salt ensures that even if two users pick the same password, their hashes look completely different. This prevents attackers from using precomputed rainbow tables to reverse common passwords instantly.

Argon2 won the Password Hashing Competition in 2015. It is designed to be slow and memory-hard. Slow means brute force takes forever. Memory-hard means the algorithm requires a large block of RAM to run. An attacker trying to crack millions of passwords simultaneously runs out of memory long before they run out of time. This makes building custom ASIC chips to speed up cracking economically unviable.

Argon2 has three variants. Argon2d uses data-dependent memory accesses, making it fast but vulnerable to side-channel attacks on shared hardware. Argon2i uses data-independent accesses, making it resistant to side-channels but slower. Argon2id combines both approaches. It uses data-dependent accesses in the first pass and data-independent in subsequent passes. This gives you side-channel resistance without sacrificing too much performance against GPU cracking. Argon2id is the recommended default for password hashing.

Minimal example

Here's the minimal setup: generate a random salt, pass it to argon2.IDKey, and encode the result.

package main

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"

	"golang.org/x/crypto/argon2"
)

// hashPassword creates a secure Argon2id hash for the given password.
func hashPassword(password []byte) (string, error) {
	// Salt prevents rainbow table attacks by ensuring identical passwords produce different hashes.
	salt := make([]byte, 16)
	if _, err := rand.Read(salt); err != nil {
		return "", err
	}

	// IDKey uses Argon2id: resistant to side-channel and GPU attacks.
	// Parameters: time=1, memory=64KB, threads=4, keyLen=32.
	hash := argon2.IDKey(password, salt, 1, 64*1024, 4, 32)
	return hex.EncodeToString(hash), nil
}

func main() {
	hash, err := hashPassword([]byte("my_secure_password"))
	if err != nil {
		fmt.Println("Error:", err)
		return
	}
	fmt.Println("Hash:", hash)
}

Install the dependency before building:

go get golang.org/x/crypto/argon2

How the parameters work

rand.Read pulls from the operating system's cryptographic random number generator. It never returns predictable data. The salt is 16 bytes, which is the standard size recommended by the Argon2 specification. This provides 128 bits of entropy, making collisions statistically impossible.

argon2.IDKey takes the password, the salt, and tuning parameters. The memory parameter is in kilobytes, so 64*1024 requests 64 megabytes. This forces the algorithm to allocate a large memory block and mix data through it repeatedly. The time parameter controls the number of passes over the memory block. The threads parameter controls parallelism. The key length is the size of the output hash in bytes. 32 bytes is standard.

Tune these parameters for your hardware. Run a benchmark on your server and adjust until hashing takes about 200 to 500 milliseconds. If hashing takes 200ms on your server, cracking takes 200ms per guess on a supercomputer. If you can afford a slower login response, increase the memory or time cost.

The if err != nil check is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. Swallowing errors from rand.Read is dangerous. If the random number generator fails, you might reuse salts or generate weak hashes. Return the error and let the caller decide.

Argon2id is the standard. Salt every password. Tune the memory.

Realistic usage

In production, you wrap the hasher in a struct so you can tune parameters per environment and inject it into your auth service. This follows the convention of accepting interfaces and returning structs. The hasher struct holds configuration, and you can swap implementations if needed.

// PasswordHasher handles secure password hashing with configurable parameters.
type PasswordHasher struct {
	// MemoryKB controls the memory usage of the hash function.
	MemoryKB uint32
	// Iterations controls the number of passes over the memory block.
	Iterations uint32
	// Threads controls parallelism.
	Threads uint8
}

// Hash computes an Argon2id hash and returns the encoded string.
func (h *PasswordHasher) Hash(password []byte) (string, error) {
	salt := make([]byte, 16)
	if _, err := rand.Read(salt); err != nil {
		// Wrap the error to preserve the context of the failure.
		return "", fmt.Errorf("failed to generate salt: %w", err)
	}

	hash := argon2.IDKey(password, salt, h.Iterations, h.MemoryKB, h.Threads, 32)
	return hex.EncodeToString(hash), nil
}

The receiver name is (h *PasswordHasher). This matches the type initial and is idiomatic Go. Avoid (this *PasswordHasher) or (self *PasswordHasher). The underscore in if _, err := rand.Read(salt) discards the byte count returned by Read. This signals that you considered the return value and chose to drop it. Use the underscore sparingly, especially with errors.

Verification and storage

Hashing is half the battle. Verification is the other half. You need to store the salt alongside the hash, or encode them together. A common pattern is storing salt:hash in the database, or using a PHC string format that embeds the parameters, salt, and hash.

When verifying, you hash the input with the stored salt and compare the result. Never use == for comparison. The == operator returns false as soon as it finds a mismatch. This creates a timing side-channel. An attacker can measure response times to guess the hash byte-by-byte. Use crypto/subtle.ConstantTimeCompare instead. It always takes the same amount of time regardless of where the mismatch occurs.

import "crypto/subtle"

// Verify checks if the password matches the stored hash.
func (h *PasswordHasher) Verify(password []byte, salt []byte, storedHash []byte) bool {
	// Recompute the hash using the stored salt and parameters.
	hash := argon2.IDKey(password, salt, h.Iterations, h.MemoryKB, h.Threads, 32)
	
	// ConstantTimeCompare prevents timing attacks by taking the same duration for all inputs.
	return subtle.ConstantTimeCompare(hash, storedHash) == 1
}

Constant time comparison isn't optional. Timing leaks are real.

Pitfalls and errors

If you forget to import the package, the compiler rejects the build with undefined: argon2. If you pass a string instead of a byte slice to IDKey, you get cannot use password (variable of type string) as []byte value in argument. Go requires explicit conversion: []byte(password).

Goroutine leaks happen when a goroutine waits on a channel that never gets closed. This doesn't apply directly to hashing, but if you wrap hashing in a worker pool, ensure the pool shuts down cleanly. The worst goroutine bug is the one that never logs.

Don't pass a *string to the hasher. Strings are cheap to pass by value. Passing a pointer adds indirection without saving memory. Public names start with a capital letter. Private names start lowercase. The Hash method is public because external code calls it. The struct fields are public so you can configure them, or you could make them private and add a constructor.

If you need to recover a password, you're using the wrong tool. Hashes are one-way. Implement a password reset flow instead.

Decision matrix

Use Argon2id when you need the strongest defense against GPU and ASIC cracking for user passwords. Use bcrypt when you are maintaining legacy systems that already store bcrypt hashes and cannot migrate. Use scrypt when you have a constrained environment that lacks Argon2 support but requires memory-hard hashing. Use PBKDF2-HMAC-SHA256 only when interoperability with older standards is mandatory and you can afford higher iteration counts. Use a simple SHA-256 hash only for non-secret data like checksums or commit signatures, never for passwords.

Where to go next