Generating and hashing passwords in Go
Go's standard library covers the random part well, and
x/crypto covers hashing with exactly the algorithms you would choose by hand.
This is also how the hashing on this site works.
1. Generate a password
math/rand is not suitable, and even the newer top-level
rand.Int() from it is a deterministic PRNG unless you seed it from the OS
yourself. Use crypto/rand: its Int() is rejection-sampled, so you
do not have to handle modulo bias by hand.
package passwords
import (
"crypto/rand"
"errors"
"math/big"
)
const (
Upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
Lower = "abcdefghijklmnopqrstuvwxyz"
Digits = "0123456789"
Symbols = "!@#$%^&*()-_=+[]{};:,.?/~"
)
// randomInt returns an unbiased value in [0, max). crypto/rand.Int already
// rejects the incomplete final block, so no manual bias handling is needed.
func randomInt(max int64) (int64, error) {
n, err := rand.Int(rand.Reader, big.NewInt(max))
if err != nil {
return 0, err
}
return n.Int64(), nil
}
func Generate(length int, alphabet string) (string, error) {
if length < 1 || len(alphabet) == 0 {
return "", errors.New("length and alphabet must be non-empty")
}
out := make([]byte, length)
for i := range out {
n, err := randomInt(int64(len(alphabet)))
if err != nil {
return "", err
}
out[i] = alphabet[n]
}
return string(out), nil
}
Adding to the same file, to guarantee one character per set and keep positions unbiased, pick one per set, fill the rest, then Fisher-Yates shuffle with the same source:
gofunc GenerateStructured(length int) (string, error) {
sets := []string{Upper, Lower, Digits, Symbols}
if length < len(sets) {
return "", errors.New("length is shorter than the number of sets")
}
chars := make([]byte, 0, length)
for _, set := range sets {
n, err := randomInt(int64(len(set)))
if err != nil {
return "", err
}
chars = append(chars, set[n])
}
pool := Upper + Lower + Digits + Symbols
for len(chars) < length {
n, err := randomInt(int64(len(pool)))
if err != nil {
return "", err
}
chars = append(chars, pool[n])
}
for i := len(chars) - 1; i > 0; i-- {
j, err := randomInt(int64(i + 1))
if err != nil {
return "", err
}
chars[i], chars[j] = chars[j], chars[i]
}
return string(chars), nil
}
2. Hash with Argon2id
go get golang.org/x/crypto/argon2. Memory is in KiB, and the output below is a
proper PHC string that carries its own parameters and salt:
import (
"crypto/rand"
"encoding/base64"
"fmt"
"golang.org/x/crypto/argon2"
)
func HashArgon2id(password string) (string, error) {
// 16 random bytes, unique per hash. Never reuse a salt.
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
const (
timeCost = 3 // iterations
memory = 64 * 1024 // KiB, so 64 MiB of memory per attempt
parallelism = 4 // lanes
keyLen = 32
)
key := argon2.IDKey([]byte(password), salt, timeCost, memory, parallelism, keyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, memory, timeCost, parallelism,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
3. Hash with bcrypt
goimport (
"strings"
"golang.org/x/crypto/bcrypt"
)
func HashBcrypt(password string) (string, error) {
// Cost 12 is a reasonable 2026 baseline; 10 is the library default and low.
encoded, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return "", err
}
// Go emits the legacy "$2a$" identifier; "$2b$" names the same algorithm and
// is what most other tools expect, so relabel it for interoperability.
hash := string(encoded)
if strings.HasPrefix(hash, "$2a$") {
hash = "$2b$" + hash[4:]
}
return hash, nil
}
Remember the 72-byte limit: bcrypt ignores input beyond it. If you generate 128-character passwords, that matters.
4. Verify — and cap the work factor
This is where a subtle denial-of-service hole lives. bcrypt takes its cost from the
submitted hash, and the library accepts costs up to 31, which is hours of CPU for a
single request. Argon2id has the same shape of problem: the memory cost comes from the
string, so a hash claiming m=4194304 asks for gigabytes. Validate before you
compute.
import (
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
)
const (
minCost, maxCost = 10, 14
minMem, maxMem = 8 * 1024, 256 * 1024 // KiB
minTime, maxTime = 1, 10
minLanes, maxLanes = 1, 4
)
func Verify(password, encoded string) (bool, error) {
switch {
case strings.HasPrefix(encoded, "$argon2id$"):
return verifyArgon2id(password, encoded)
case strings.HasPrefix(encoded, "$2a$"), strings.HasPrefix(encoded, "$2b$"),
strings.HasPrefix(encoded, "$2y$"):
// Bound the cost BEFORE hashing, or a crafted "$2b$31$..." burns hours.
cost, err := bcrypt.Cost([]byte(encoded))
if err != nil {
return false, errors.New("invalid bcrypt hash")
}
if cost < minCost || cost > maxCost {
return false, fmt.Errorf("bcrypt cost must be between %d and %d", minCost, maxCost)
}
err = bcrypt.CompareHashAndPassword([]byte(encoded), []byte(password))
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, nil
}
return err == nil, err
default:
return false, errors.New("unsupported hash format")
}
}
func verifyArgon2id(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 {
return false, errors.New("malformed Argon2id hash")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version {
return false, errors.New("unsupported Argon2id version")
}
var memory, timeCost uint32
var lanes uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &lanes); err != nil {
return false, errors.New("malformed Argon2id parameters")
}
// The same ceiling applies to parameters read out of an untrusted string.
if memory < minMem || memory > maxMem || timeCost < minTime ||
timeCost > maxTime || lanes < minLanes || lanes > maxLanes {
return false, errors.New("Argon2id parameters exceed allowed limits")
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, errors.New("malformed salt")
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, errors.New("malformed hash")
}
got := argon2.IDKey([]byte(password), salt, timeCost, memory, lanes, uint32(len(want)))
// Constant-time comparison: never leak how many bytes matched.
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
This is close to the code running behind the hashing tools on this site, including the parameter ceilings on both paths and the constant-time comparison.
Pitfalls in Go
math/rand, includingrand.Intn()and the pre-1.20 global source, is deterministic. Usecrypto/rand.- Modulo shortcuts.
crypto/randgives you bytes, not uniform numbers.int(b[0]) % 62is biased; userand.Int()or implement rejection sampling. - Unbounded verify parameters. As above: validate the cost, memory, iterations and lane count you read out of a hash before you spend CPU on it.
- Casting to
stringearly. A Go string is immutable, so a password converted to a string cannot be wiped; keep it as[]bytewhen you can, and clear the slice after use. - Storing
keywithout the parameters. Keep the PHC string intact so future verification knows how the hash was made — and soupgrade-on-logincan detect when it is time to re-hash.
Running it
Everything in this article compiles with the standard toolchain and one dependency:
shellgo mod init example.com/passwords
go get golang.org/x/crypto
go test ./...
The service behind this site runs this exact logic — hash or verify a password to compare its output with your own implementation's.