← All articles

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.

go
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:

go
func 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:

go
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

go
import (
	"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.

go
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, including rand.Intn() and the pre-1.20 global source, is deterministic. Use crypto/rand.
  • Modulo shortcuts. crypto/rand gives you bytes, not uniform numbers. int(b[0]) % 62 is biased; use rand.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 string early. A Go string is immutable, so a password converted to a string cannot be wiped; keep it as []byte when you can, and clear the slice after use.
  • Storing key without the parameters. Keep the PHC string intact so future verification knows how the hash was made — and so upgrade-on-login can detect when it is time to re-hash.

Running it

Everything in this article compiles with the standard toolchain and one dependency:

shell
go 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.