How to Hash Passwords in Go with bcrypt

Web
Hash passwords in Go using the bcrypt package with GenerateFromPassword and CompareHashAndPassword functions.

The login leak scenario

You are building a signup form. A user enters a password, your handler saves it to the database, and the app works. Six months later, a misconfigured cloud bucket exposes your user table. If you stored the password as plain text, every account is compromised instantly. Attackers can log in, steal data, and pivot to other services where users reuse passwords. If you stored a cryptographic hash, the leak is just noise. The attacker sees gibberish and cannot recover the original password.

Password hashing is the difference between a minor infrastructure incident and a total security breach. Go does not include a password hashing function in the standard library, but the community relies on a single, well-maintained package that handles the hard parts correctly.

Hashing versus encryption

Encryption is a lockbox. You put data in, lock it with a key, and anyone with the key can open it and get the data back. Hashing is a blender. You put ingredients in, run the machine, and get a smoothie. You cannot un-blend the smoothie to get the strawberries back.

Passwords need a blender, not a lockbox. If you encrypt passwords, you must store the decryption key somewhere. If an attacker finds the key, they can decrypt every password. A hash has no key. It is a one-way transformation. The only way to check a password is to hash the input and compare the result to the stored hash.

Hashing also solves the "same password" problem. If two users choose password123, a naive hash produces the same output for both. An attacker can pre-compute hashes for common passwords (a rainbow table) and match them instantly. The solution is a salt: random data added to the password before hashing. The salt ensures that even identical passwords produce different hashes. bcrypt generates a random salt automatically and embeds it in the output string, so you never have to manage salt storage manually.

Hashing is a one-way street. Design your system so you never need to recover a password, only reset it.

The minimal workflow

The golang.org/x/crypto/bcrypt package is the standard tool for password hashing in Go. It provides two functions that cover 99% of use cases: GenerateFromPassword creates the hash, and CompareHashAndPassword verifies it. The package handles salt generation, cost tuning, and encoding behind the scenes.

Here is the core loop: hash on signup, compare on login.

package main

import (
	"fmt"
	"log"

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

func main() {
	// bcrypt operates on byte slices; convert the string password
	password := []byte("mySecretPassword")

	// GenerateFromPassword creates a random salt, hashes the password, and encodes the result
	// DefaultCost is 10, which balances security and latency for most servers
	hashed, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
	if err != nil {
		log.Fatal(err)
	}

	// The output string contains the algorithm version, cost, salt, and hash
	// You can store this entire string in your database as a single field
	fmt.Println("Hash:", string(hashed))

	// CompareHashAndPassword parses the hash, extracts the salt and cost, and verifies the input
	// It returns nil on success, not a boolean
	err = bcrypt.CompareHashAndPassword(hashed, password)
	if err != nil {
		fmt.Println("Password mismatch")
	} else {
		fmt.Println("Password matches")
	}
}

The hash string looks like $2a$10$.... The 2a indicates the algorithm variant. The 10 is the cost parameter. The remaining characters encode the salt and the derived hash. CompareHashAndPassword reads this format, so you do not need to store the salt or cost in separate columns. The hash is self-describing.

The hash contains everything. Store the string, forget the salt.

How bcrypt protects you

The cost parameter controls how much work the hash function does. bcrypt is designed to be slow. The cost is an exponent: cost 10 means 2^10 iterations, cost 11 means 2^11, and so on. Each increment doubles the computation time. This slowness is a feature. An attacker trying to brute-force passwords must run the hash function billions of times. If the function takes 100 milliseconds, the attacker can only try a few thousand guesses per second. If you raise the cost to 12, the function takes 400 milliseconds, and the attacker's speed drops by a factor of four.

The CompareHashAndPassword function also uses a constant-time comparison. A naive string comparison stops as soon as it finds a mismatching character. An attacker can measure the response time to guess how many characters are correct. Constant-time comparison takes the same amount of time regardless of where the mismatch occurs, preventing timing side-channel attacks.

You do not need to implement constant-time logic yourself. The library handles it. Trust the implementation.

Realistic usage in a domain type

In production code, hashing logic belongs in your domain layer, not scattered across HTTP handlers. Wrap the password field in a struct and add methods to hash and check. This keeps handlers thin and centralizes security rules.

package main

import (
	"log"

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

// User represents an account with a hashed password
type User struct {
	Username string
	Password []byte
}

// HashPassword hashes the plain text password and stores it in the user struct
func (u *User) HashPassword(plainText string) error {
	// bcrypt expects bytes; convert the string input
	hash, err := bcrypt.GenerateFromPassword([]byte(plainText), bcrypt.DefaultCost)
	if err != nil {
		return err
	}

	// Store the hash, not the plain text
	u.Password = hash
	return nil
}

// CheckPassword verifies the plain text against the stored hash
func (u *User) CheckPassword(plainText string) bool {
	// CompareHashAndPassword returns nil on success
	err := bcrypt.CompareHashAndPassword(u.Password, []byte(plainText))
	return err == nil
}

The receiver name u follows Go convention: one or two letters matching the type name. The method HashPassword is public because it starts with a capital letter. The Password field is a byte slice because bcrypt works with bytes, and database drivers often map byte slices to binary columns.

The if err != nil pattern is verbose by design. The Go community accepts the boilerplate because it makes error handling explicit. You cannot accidentally swallow a hashing failure.

Domain logic lives in the type. Keep handlers thin.

Pitfalls and edge cases

bcrypt has a hard limit: passwords longer than 72 bytes are truncated. If a user pastes a novel as their password, the hash only covers the first 72 bytes. The library returns an error when you try to hash a password that exceeds this limit. The compiler will not catch this. The function returns bcrypt: password too long (>72). You should validate password length in your application logic before calling the hash function, or handle the error gracefully.

Another common mistake is treating the error from CompareHashAndPassword as a system failure. The function returns an error when the password is wrong. It also returns an error if the hash format is invalid. You must distinguish between "wrong password" and "corrupt data". In most cases, you can treat any error from CompareHashAndPassword as a failed login, but logging the error helps you detect database corruption.

As hardware improves, you will need to raise the cost parameter. If you increase the cost in your code, existing users with old hashes are not broken. CompareHashAndPassword extracts the cost from the stored hash and uses it for verification. You can upgrade old hashes transparently by re-hashing on the next successful login.

// CheckPasswordAndRehash verifies the password and re-hashes if the cost has changed
func (u *User) CheckPasswordAndRehash(plainText string, newCost int) error {
	// Verify the password against the stored hash
	err := bcrypt.CompareHashAndPassword(u.Password, []byte(plainText))
	if err != nil {
		return err
	}

	// Extract the cost from the existing hash to check if it needs updating
	_, existingCost, err := bcrypt.Cost(u.Password)
	if err != nil {
		return err
	}

	// Re-hash if the cost increased, improving security for existing users
	if existingCost != newCost {
		return u.HashPassword(plainText)
	}
	return nil
}

The bcrypt.Cost function parses the hash string to retrieve the cost parameter. Re-hashing on login upgrades security without forcing users to reset their passwords. The new hash is saved to the database, and the next login uses the stronger cost.

Upgrade hashes on login. Security is a process, not a one-time setup.

When to use bcrypt and when to look elsewhere

Password hashing has trade-offs. bcrypt is the default choice for most applications, but other algorithms exist for specific needs.

Use bcrypt when you need a battle-tested, simple password hash with automatic salting and adjustable cost. It is widely supported, easy to use, and sufficient for the vast majority of web applications.

Use scrypt or Argon2 when you need memory-hard hashing to resist GPU and ASIC attacks. These algorithms require significant memory to compute, making specialized hardware less effective. They are more complex to configure and may not be necessary unless you are protecting high-value targets.

Use a constant-time comparison function when comparing secrets that are not passwords, such as API tokens or session IDs. The crypto/subtle package provides subtle.ConstantTimeCompare for this purpose.

Never use MD5, SHA-1, or SHA-256 for passwords. These functions are designed to be fast. An attacker can compute billions of hashes per second on a single GPU, cracking weak passwords in seconds.

Never roll your own crypto. The golang.org/x/crypto package is maintained by experts and audited by the community. Custom implementations almost always contain subtle flaws.

bcrypt is the default for a reason. Pick it and move on.

Where to go next