passwordhash

passwordhash provides OWASP-compliant password hashing and verification using the Argon2id algorithm (RFC 9106), with an optional AES-GCM encryption layer (peppered hashing) for defense in depth.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/passwordhash"

Package passwordhash provides OWASP-compliant password hashing and verification using the Argon2id algorithm (RFC 9106), with an optional AES-GCM encryption layer (peppered hashing) for defense in depth.

Usage

The methods operate on a single Params configuration object, following the OWASP Password Storage Cheat Sheet recommendations (https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html):

p := passwordhash.New() // RFC 9106 §4 defaults

// Hash a password for storage.
hash, err := p.PasswordHash(plaintext)

// Verify a login attempt.
ok, err := p.PasswordVerify(plaintext, hash)

// After a successful verification, detect hashes minted with outdated
// parameters so they can be transparently re-hashed and stored again.
upgrade, err := p.PasswordNeedsRehash(hash)

For deployments that store a secret pepper outside the database, the encrypted variants add an AES-GCM layer on top of the Argon2id hash:

hash, err := p.EncryptPasswordHash(pepper, plaintext)
ok, err  := p.EncryptPasswordVerify(pepper, plaintext, hash)

Storage Format

The hashed password is stored as a base64-encoded JSON object that is self-describing: it embeds the algorithm name, version, all Argon2id tuning parameters, the random salt, and the derived key. This makes the stored value portable across languages and systems, and allows parameters to be upgraded without invalidating existing hashes.

Example JSON (before base64 encoding):

{
  "P": {
    "A": "argon2id",  // algorithm name (always "argon2id")
    "V": 19,          // Argon2id version (0x13)
    "K": 32,          // derived key length in bytes
    "S": 16,          // salt length in bytes
    "T": 3,           // time cost (passes over memory)
    "M": 65536,       // memory cost in KiB
    "P": 4            // parallelism (threads)
  },
  "S": "wQYm4bfktbHq2omIwFu+4Q==",                       // base64 random salt
  "K": "aU8hO900Odq6aKtWiWz3RW9ygn734liJaPtM6ynvkYI="   // base64 Argon2id hash
}

The final stored value is the base64 encoding of the above JSON (~200 bytes).

Alternatively, WithFormat selects the PHC string format shared by Argon2 implementations across languages:

$argon2id$v=19$m=65536,t=3,p=4$<base64 salt>$<base64 key>

The JSON format is self-contained but is a nurago-specific schema; the PHC format is what external tooling (PHP’s password_hash, Python’s argon2-cffi and passlib, the Argon2 reference CLI) reads and writes directly. Params.PasswordVerify, Params.PasswordNeedsRehash, and their encrypted counterparts auto-detect which format a stored value uses (a leading ‘$’ marks PHC), so switching formats never invalidates an existing hash. Params.PasswordNeedsRehash also steers format migration: a hash stored in a format outside the configured accepted set (see WithFormat) is reported as needing a rehash, so an existing store converges to the configured format through the ordinary rehash-on-login flow, or stays deliberately mixed when both formats are listed as accepted.

The accepted PHC envelope is deliberately strict: argon2id only (a PHC string minted by an argon2i or argon2d implementation fails with ErrAlgoMismatch), version 19 only, cost parameters in the standard m,t,p order with the optional keyid and data attributes rejected, threads up to 255, canonical unpadded base64 for salt and key (embedded newlines and non-canonical trailing bits are rejected), and the same deserialization bounds that protect the JSON format.

Features

Hashing uses Argon2id (RFC 9106) with a fresh cryptographically random salt per hash and a constant-time final comparison (crypto/subtle). The self-describing storage format carries the algorithm, version, and all parameters with the hash, so no separate migration table is needed when tuning changes. Params.EncryptPasswordHash and Params.EncryptPasswordVerify add an optional AES-GCM pepper layer keyed outside the database, so a database leak alone cannot mount an offline attack. Input length is bounded before any Argon2 work and oversized stored hash strings are rejected before decoding; the separate cost of the Argon2 parameters embedded in a stored hash is bounded at verification by the verify-cost multiplier (see WithVerifyCostMultiplier). Params.PasswordNeedsRehash and Params.EncryptPasswordNeedsRehash detect hashes minted with outdated parameters so they can be re-hashed on the next successful login. Every failure class is matchable with errors.Is. WithTime, WithMemory, WithThreads, WithKeyLen, WithSaltLen, WithMinPasswordLength, and WithMaxPasswordLength tune the parameters, and WithFormat selects JSON or PHC serialization; verification auto-detects and accepts argon2id PHC hashes produced by other implementations (PHP’s password_hash, Python’s argon2-cffi and passlib, the Argon2 reference CLI).

Verification Flow

  1. Reject the stored string before any decoding if it exceeds the maximum accepted size (16 KiB), bounding the cost of untrusted input.
  2. Detect the serialization (a leading ‘$’ marks PHC, otherwise base64 JSON) and decode the stored string to recover algorithm, version, parameters, and salt.
  3. For PHC, reconstruct the salt and key lengths from the decoded byte lengths.
  4. Validate that the embedded parameters are within accepted bounds and internally consistent (the salt and key byte lengths match their declared sizes, and the embedded time and memory cost do not exceed the verify-cost multiplier times this configuration’s own cost), rejecting forged or corrupted blobs before any computation.
  5. Validate that the stored algorithm and version match the library.
  6. Re-derive the key from the candidate password using the stored parameters and salt.
  7. Compare the derived key against the stored key with crypto/subtle.ConstantTimeCompare to prevent timing attacks.

Parameter Tuning

The defaults (T=3, M=64 MiB, P=4) match the second recommended option set of RFC 9106 §4 (https://datatracker.ietf.org/doc/html/rfc9106#section-4). Parallelism is a flat constant, deliberately not derived from runtime.NumCPU(): Argon2 lanes are goroutines, so p=4 is valid on any host, and a machine-independent default keeps the work factor reproducible across heterogeneous fleets. With a per-machine default, hosts with different core counts would mint hashes with different parameters and Params.PasswordNeedsRehash would report an upgrade on every alternating login, re-hashing forever. Benchmark Params.PasswordHash on representative hardware and adjust via WithTime, WithMemory, and WithThreads so that hashing takes 0.5 to 1 s under your expected load.

When To Use

  • You store user passwords and need a current, memory-hard algorithm.
  • Hashes must be portable, in the standard PHC string format.
  • Defense in depth calls for a pepper: an AES-GCM layer over the hash, keyed outside the database.

Example

opts := []passwordhash.Option{
	passwordhash.WithKeyLen(32),
	passwordhash.WithSaltLen(16),
	passwordhash.WithTime(3),
	passwordhash.WithMemory(16_384),
	passwordhash.WithThreads(1),
	passwordhash.WithMinPasswordLength(16),
	passwordhash.WithMaxPasswordLength(128),
}

p := passwordhash.New(opts...)

secret := "Example-Password-01"

hash, err := p.PasswordHash(secret)
if err != nil {
	log.Fatal(err)
}

ok, err := p.PasswordVerify(secret, hash)
if err != nil {
	log.Fatal(err)
}

fmt.Println(ok)

ok, err = p.PasswordVerify("Example-Wrong-Password-01", hash)
if err != nil {
	log.Fatal(err)
}

fmt.Println(ok)

// Output:
// true
// false

Full source is in example_passwordhash_test.go. More runnable examples are on pkg.go.dev.

Dependencies

Importing this package pulls 2 external modules:

  • golang.org/x/crypto
  • golang.org/x/sys