Table of contents
Password storage is one of the more consequential things a service does, and it has a long record of being botched. General-purpose hashes like MD5 and SHA-1 are the classic mistake: fast, and trivially parallelised on a GPU, which is precisely backwards.
What the job calls for is a hash that is deliberately slow and memory-hard, needing a large tunable amount of RAM per guess. That is what neutralises the massively parallel GPU and Application-Specific Integrated Circuit (ASIC) rigs used to crack leaked password databases. Bcrypt is slow but uses a fixed, tiny amount of memory, so those rigs still scale against it. Password-Based Key Derivation Function 2 (PBKDF2), the usual choice when Federal Information Processing Standards (FIPS) compliance is required, iterates a cheap hash and is not memory-hard at all. Scrypt is memory-hard, but ties memory and CPU cost to a single knob. Argon2, the Password Hashing Competition (PHC) winner standardised as RFC 9106, lets you dial time, memory, and parallelism independently, and its id variant adds resistance to side-channel attacks. Hence its place at the top of the Open Worldwide Application Security Project (OWASP) Password Storage Cheat Sheet.
The passwordhash package in nurago implements that cheat sheet’s advice as three method pairs on one configuration object: hash and verify, their pepper-encrypted variants, and a rehash check. Below, a single password is traced through the system: the day its hash is minted, the years it sits in a database, the logins that verify it, and the day the parameters change underneath it.
Minting
Registration day:
import "github.com/tecnickcom/nurago/pkg/passwordhash"
p := passwordhash.New() // RFC 9106 §4 defaults
hash, err := p.PasswordHash(plaintext)
New gives you Argon2id with time cost T=3, memory M=64 MiB, parallelism P=4, a 16-byte salt, and a 32-byte derived key. Those numbers are the second recommended option set of RFC 9106 §4, not the OWASP ones, which is worth knowing before an auditor asks. The OWASP cheat sheet states a minimum of m=19 MiB, t=2, p=1, and the RFC 9106 set sits comfortably above that floor.
Every call generates a fresh cryptographically random salt, so two hashes of the same password differ. Password length is policed before any expensive computation, 8 to 4096 bytes by default, counted in bytes rather than characters. For production you are still expected to benchmark on your own hardware and raise the cost with WithTime, WithMemory, and WithThreads until a hash takes half a second to a second under load.
Parallelism is the parameter with a trap in it. The instinct is to tie it to the hardware: p = runtime.NumCPU(). Use the cores you have.
That instinct is wrong here, because Argon2’s parameters, p included, become part of the stored hash, and the rehash check decides staleness by comparing a hash’s embedded parameters against the current configuration. Take a fleet of heterogeneous hosts, a mix of 4-, 8-, and 16-core machines. With a NumCPU-derived p, a login landing on a 16-core box mints a hash with p=16. The next login lands on an 8-core box whose configuration says p=8, so the hash reads as outdated and is re-minted at p=8. The login after that hits a 4-core box and re-mints again. The hash never stabilises. Every alternating login pays the full cost of a rehash, forever, for no security benefit.
So passwordhash makes parallelism a flat constant, p=4, the RFC 9106 §4 recommendation, deliberately not derived from runtime.NumCPU(). Argon2 lanes are goroutines, so p=4 is valid on any host whatever its core count, and a machine-independent default keeps the work factor reproducible across the fleet. The reasoning sits in the source right next to the constant, because a future maintainer’s obvious improvement would put the bug straight back.
That same shape turns up anywhere a value becomes part of a stored artefact and later feeds a staleness decision about that artefact. If it varies across the fleet, you get churn.
Storage: a hash that describes itself
What lands in the database carries everything needed to verify it later. There is no separate table of “which parameters did we use in 2024”. By default the stored string is base64-encoded JSON (about 200 bytes) embedding the algorithm, the Argon2 version, all tuning parameters, the salt, and the derived key:
{
"P": { "A": "argon2id", "V": 19, "K": 32, "S": 16, "T": 3, "M": 65536, "P": 4 },
"S": "wQYm4bfktbHq2omIwFu+4Q==",
"K": "aU8hO900Odq6aKtWiWz3RW9ygn734liJaPtM6ynvkYI="
}
Everything downstream depends on that self-description. Because the parameters travel with the hash, verification can re-derive the key exactly as it was minted, and migration can compare what a hash is against what you now want without consulting any external record. Raise T, M, or the key and salt lengths whenever you like. Existing users keep logging in against their old hashes, and the stronger cost applies to hashes minted from that point on.
The JSON schema is nurago-specific, though: no other library reads it. For interoperability the package also speaks the PHC string format, the encoding shared by Argon2 implementations across ecosystems (PHP’s password_hash, Python’s argon2-cffi and passlib, the Argon2 reference command-line tool):
$argon2id$v=19$m=65536,t=3,p=4$<base64 salt>$<base64 key>
WithFormat(passwordhash.FormatPHC) switches the emitted serialisation. Reading does not have to be told which format it is looking at: a PHC string starts with $, and the base64 alphabet of the JSON format does not contain that character, so the format is detected from the stored value itself. A Params configured either way reads both formats, so the two can sit side by side in the same table.
Verification: every login, forever
At login the stored string is decoded and its embedded parameters validated. The submitted password is re-hashed with the stored parameters and salt, and the result compared with crypto/subtle.ConstantTimeCompare, so the comparison does not leak its own outcome through timing. The freshly derived key is then wiped with clear, a best-effort measure, since Go cannot guarantee no copies remain after stack growth or garbage collection.
One asymmetry is deliberate. The minimum-length policy is enforced only when hashing, never when verifying, so raising the minimum cannot lock out users whose passwords predate it. The maximum-length guard applies on both paths, because it bounds the cost an attacker can force with a giant input.
Distinct mint and verify envelopes. New hashes must clear stricter floors than verification accepts. A fresh hash needs at least a 16-byte key and an 8-byte salt, the package’s own floor for 128-bit strength, where RFC 9106 §3.1 permits tags down to 4 bytes. The verify path accepts keys down to 4 bytes and salts down to 1 byte, so looser legacy hashes stay readable. The direction matters: the mint envelope sits inside the verify envelope, so whatever a configuration can mint, that same configuration can verify. A parameter set that could mint an unverifiable hash would be a total lockout discovered at first login, so the mint path enforces the verify ceilings too.
The verify-cost cap. Verification obeys the parameters in the stored blob, which makes the blob an instruction to spend resources. The absolute ceilings bound the damage: 1024 passes, 4 GiB of memory, and a 16 KiB limit on the encoded string checked before any decoding. But the string-length guard bounds only the string, not the cost the embedded parameters demand. One forged or corrupt row declaring near-ceiling parameters could pin the verifier at 4 GiB and minutes of CPU on every login attempt against that account, a targeted resource-exhaustion amplifier sitting in your own database.
Embedded time and memory are therefore also capped at a multiple of the verifier’s own configured cost, 4x by default, tunable with WithVerifyCostMultiplier and clamped to a minimum of 1. Anything above the band is rejected as ErrInvalidHashData before any Argon2 work runs. A freshly minted hash costs exactly 1x, and a hash minted under a cheaper past configuration sits below that, so the default band never touches ordinary operation. Lower the multiplier towards 1 if a stored hash could ever be attacker-influenced. Raise it temporarily before a single large step up in configured cost. Revisit it if you ever lower the configured cost, since hashes minted under the older, stronger settings then sit higher in the band.
Migration: the day the parameters change
Eventually you raise the cost, or switch formats, or import hashes from another system. The library detects staleness; your login handler re-mints:
ok, err := p.PasswordVerify(plaintext, stored)
if err != nil || !ok {
return // authentication failed, reject
}
// Password is correct. Opportunistically upgrade the stored hash.
if upgrade, _ := p.PasswordNeedsRehash(stored); upgrade {
if fresh, err := p.PasswordHash(plaintext); err == nil {
_ = save(userID, fresh) // persist the stronger hash
}
}
PasswordNeedsRehash reports true when the stored algorithm, version, key length, salt length, time, memory, or threads differ from the current configuration, or when the stored serialisation is not among the accepted formats. Skip the block and nothing breaks: old hashes keep verifying at their original strength indefinitely, they simply do not get stronger.
Format awareness turns this loop into a migration tool. A hash stored in a format your configuration does not accept is flagged exactly as an outdated cost factor would be, so the same rehash-on-login flow that upgrades parameters also converges formats. Import a pile of password_hash hashes from a legacy PHP service, point nurago at them, and they verify on the first login and re-mint into your configured format on the way out. No bulk conversion, no flag day. When you want a deliberately mixed store instead, list both formats as accepted:
// Emit PHC, but treat existing JSON hashes as current too, so they are not rehashed.
passwordhash.New(passwordhash.WithFormat(passwordhash.FormatPHC, passwordhash.FormatJSON))
“Transparent migration” is easy to overclaim, so two boundaries deserve stating plainly.
Self-description covers the cost factors, and stops at the algorithm and version. Those are checked for equality at verification, and a mismatch is rejected with a sentinel error, ErrAlgoMismatch or ErrVersionMismatch, never silently re-derived. An underlying Argon2 version bump would be a real migration, not a free upgrade.
That accepted PHC envelope is also narrow on purpose: argon2id only, so a string minted by argon2i or argon2d fails with ErrAlgoMismatch; version 19 only; cost parameters in the standard m,t,p order; the optional keyid and data attributes rejected; threads up to 255; and canonical unpadded standard base64 with strict trailing-bit validation, embedded newlines included, so that two byte-different strings cannot decode to the same salt and key. That is narrower than the PHC specification permits. It covers what this package can mint and safely re-derive.
The rest of the toolbox
For deployments that keep a secret outside the database, EncryptPasswordHash and EncryptPasswordVerify wrap the whole envelope in an Advanced Encryption Standard, Galois/Counter Mode (AES-GCM) layer keyed by a pepper of 16, 24, or 32 bytes held in a secrets manager. A database leak alone is then not enough to mount an offline attack. The decrypted key and salt are wiped after use, and EncryptPasswordNeedsRehash keeps the migration loop working.
Every failure class across the package is an errors.Is-matchable sentinel, so callers can tell “wrong password” apart from “malformed stored hash” and from “invalid configuration”.
All of it costs about 200 bytes per user in the database, one call at registration, one at login, and one conditional re-mint. The rest is defaults you raise as your hardware gets faster.