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: they are fast and trivially parallelised on a GPU, which is the opposite of what you want. The job calls for a hash that is deliberately slow and memory-hard: one that needs a large, tunable amount of RAM per guess, which 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, just 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. That combination is why the Open Worldwide Application Security Project (OWASP) Password Storage Cheat Sheet lists it first.
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. Rather than walk the API surface, this post follows a single password through the system in four movements: the day its hash is minted, the years it sits in a database, every login that verifies it, and the day the parameters change underneath it. Each movement hides one design decision worth understanding.
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. The provenance matters, and it is easy to get wrong: these are the second recommended option set of RFC 9106 §4, not the OWASP numbers. The OWASP cheat sheet states a minimum configuration of m=19 MiB, t=2, p=1; the RFC 9106 set is comfortably stronger than that floor. Every call generates a fresh cryptographically random salt, so two hashes of the same password differ, and password length is policed (8 to 4096 bytes by default, counted in bytes, not characters) before any expensive computation. For production you are still expected to benchmark on your own hardware and raise the cost via WithTime, WithMemory, and WithThreads until a hash takes roughly half a second to a second under load.
The parameter worth dwelling on is parallelism, because it hides this article’s central trap. The natural instinct is to tie it to the hardware: p = runtime.NumCPU(). It feels principled: use the cores you have. It is a trap, because Argon2’s parameters, p included, become part of the stored hash, and the rehash check we will meet in the final movement decides whether a hash is stale by comparing its embedded parameters against the current configuration. Now picture 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.
passwordhash therefore 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 regardless of core count, and a machine-independent default keeps the work factor reproducible across the whole fleet. The reasoning is spelled out in the source right next to the constant, because a future maintainer’s “obvious improvement” would reintroduce exactly this bug. The lesson generalises: any value that becomes part of a stored artefact, and later feeds a staleness decision about that artefact, must be deterministic across the fleet, or 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="
}
Self-description is the property the rest of the lifecycle depends on. 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, its embedded parameters are validated, the submitted password is re-hashed with the stored parameters and salt, and the result is compared with crypto/subtle.ConstantTimeCompare, so the comparison itself does not leak the result through its timing. The freshly derived key is then wiped with clear (a best-effort measure: Go cannot guarantee no copies remain after stack growth or garbage collection). One deliberate asymmetry: 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.
Two less obvious design points live here.
Distinct mint and verify envelopes. New hashes must meet 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; RFC 9106 §3.1 itself permits tags down to 4 bytes), while the verify path accepts keys down to 4 bytes and salts down to 1 byte so looser legacy hashes stay readable. The direction of the asymmetry is the point: the mint envelope sits inside the verify envelope, so whatever a configuration can mint, that same configuration can also verify. A parameter set that could mint an unverifiable hash would be a total lockout discovered at first login, which is why the mint path enforces the verify ceilings too.
The verify-cost cap. Because verification obeys the parameters in the stored blob, the blob itself is an instruction to spend resources. The absolute ceilings (1024 passes, 4 GiB of memory, and a 16 KiB limit on the encoded string, checked before any decoding) bound the damage, but the string-length guard bounds only the string, not the cost the embedded parameters demand. A single forged or corrupt row declaring near-ceiling parameters could pin the verifier at up to 4 GiB and minutes of CPU on every login attempt against that account: a targeted resource-exhaustion (denial of service, DoS) amplifier sitting quietly in your own database. The package closes this: the embedded time and memory are also capped at a multiple of the verifier’s own configured cost, 4x by default, tunable with WithVerifyCostMultiplier (clamped to a minimum of 1). Anything above the band is rejected as ErrInvalidHashData before any Argon2 work. A freshly minted hash costs exactly 1x, and a hash minted under a cheaper past configuration sits below that, so the default band leaves ordinary operation untouched. 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; and 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.
The format awareness is what 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 quietly 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))
Two boundaries are worth stating plainly, because “transparent migration” is easy to overclaim. First, self-description covers the cost factors, not the algorithm or version: those are checked for equality at verification, and a mismatch is rejected with a sentinel error (ErrAlgoMismatch, ErrVersionMismatch), never silently re-derived, so an underlying Argon2 version bump would be a real migration, not a free upgrade. Second, the accepted PHC envelope is deliberately narrow: argon2id only (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 are rejected too, so two byte-different strings do not decode to the same salt and key). This is not everything the PHC specification permits: it is exactly what this package can mint and safely re-derive, and nothing it cannot.
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 (16, 24, or 32 bytes) held in a secrets manager, so a database leak alone is 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 distinguish “wrong password” from “malformed stored hash” from “invalid configuration”.
None of this is glamorous. All of it is the difference between a demo and a credential store you can leave running for a decade, quietly upgrading itself one login at a time.