Hash new passwords with argon2.IDKey from golang.org/x/crypto at or above OWASP's minimum (memory 19456 KiB, 2 passes, 1 lane) with a 16-byte salt from crypto/rand, and store a PHC string with the parameters. Check legacy SHA-256 rows with subtle.ConstantTimeCompare, and after a successful login rehash legacy rows and any hash below the current parameters.
- Find every password stored with sha256, sha512, md5 or sha1, salted or not, and every hand-rolled loop of a fast hash. A fast hash costs an attacker one call per guess, whatever the salt.
- Hash new passwords with argon2.IDKey(password, salt, 2, 19456, 1, 32) and a 16-byte salt from crypto/rand, or one of OWASP's equal-strength pairs with more memory or more passes. x/crypto returns raw bytes, so encode the algorithm, version, parameters, salt and key as a PHC string ($argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>, unpadded base64).
- On login, recompute the key with the parameters parsed from the stored string and compare with subtle.ConstantTimeCompare. Recognise legacy SHA-256 rows by their shape and compare their hex digests in constant time too.
- After a successful login only, rehash when the row is legacy or its parameters differ from the current policy, and write it back with UPDATE ... WHERE id = ? AND password_hash = <the hash you verified>, so that a password change made in the meantime wins.
- Verify against a dummy hash when the username does not exist, and answer both cases with the same 401, so neither the body nor the timing reveals which accounts exist.
- Cap the password at a generous length (256 characters here, counted with utf8.RuneCountInString) and never truncate it; add regression tests that read the users table the way an attacker with a copy would.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
func (Vulnerable) Hash(password string) (string, error) {
sum := sha256.Sum256([]byte(password))
return hex.EncodeToString(sum[:]), nil
}With IMPL=vulnerable, 4 of 11 tests fail (4 exploit tests).
The fix: hashing policy and PHC string
// OWASP minimum for Argon2id: 19 MiB (19456 KiB) of memory, 2 passes, 1 lane. Raise memory and
// passes as far as login latency and memory allow: each hash records its own parameters, so
// older hashes keep verifying and are upgraded at the next login.
const (
argonMemoryKiB = 19456
argonPasses = 2
argonLanes = 1
argonSaltLen = 16
argonKeyLen = 32
)
// x/crypto/argon2 returns raw bytes, so store them in the PHC string format that libargon2,
// argon2-cffi and others read: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>, unpadded base64.
var b64 = base64.RawStdEncoding
func (Fixed) Hash(password string) (string, error) {
salt := make([]byte, argonSaltLen)
rand.Read(salt)
key := argon2.IDKey([]byte(password), salt, argonPasses, argonMemoryKiB, argonLanes, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemoryKiB, argonPasses, argonLanes, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
}With IMPL=fixed, 11 of 11 tests pass.
The fix: verify and upgrade
var legacySHA256 = regexp.MustCompile(`^[0-9a-f]{64}$`)
func (Fixed) Verify(password, stored string) Verification {
if legacySHA256.MatchString(stored) {
// A row from the old system: unsalted SHA-256. Compare in constant time, and ask for a
// rehash so the next successful login replaces it.
sum := sha256.Sum256([]byte(password))
valid := subtle.ConstantTimeCompare([]byte(hex.EncodeToString(sum[:])), []byte(stored)) == 1
return Verification{Valid: valid, NeedsRehash: valid}
}
h, err := parseArgon2id(stored)
if err != nil {
return Verification{}
}
key := argon2.IDKey([]byte(password), h.salt, h.passes, h.memoryKiB, h.lanes, h.keyLen)
if subtle.ConstantTimeCompare(key, h.key) != 1 {
return Verification{}
}
// Rehash when any parameter differs from the current policy.
current := h.memoryKiB == argonMemoryKiB && h.passes == argonPasses && h.lanes == argonLanes &&
len(h.salt) == argonSaltLen && len(h.key) == argonKeyLen
return Verification{Valid: true, NeedsRehash: !current}
}
type argon2idHash struct {
memoryKiB, passes, keyLen uint32
lanes uint8
salt, key []byte
}
func parseArgon2id(stored string) (argon2idHash, error) {
var h argon2idHash
parts := strings.Split(stored, "$") // "", "argon2id", "v=19", "m=..,t=..,p=..", salt, hash
if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" || parts[2] != fmt.Sprintf("v=%d", argon2.Version) {
return h, errors.New("not an Argon2id v19 PHC string")
}
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &h.memoryKiB, &h.passes, &h.lanes); err != nil {
return h, err
}
// IDKey panics on zero passes or lanes; refuse them, and memory below 8 KiB per lane.
if h.passes < 1 || h.lanes < 1 || h.memoryKiB < 8*uint32(h.lanes) {
return h, errors.New("invalid Argon2id parameters")
}
var err error
if h.salt, err = b64.DecodeString(parts[4]); err != nil {
return h, err
}
if h.key, err = b64.DecodeString(parts[5]); err != nil {
return h, err
}
n := len(h.key)
if n < 16 || n > 64 {
return h, errors.New("invalid Argon2id hash length")
}
h.keyLen = uint32(n)
return h, nil
}With IMPL=fixed, 11 of 11 tests pass.
The fix: write the upgrade at login
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
c, ok := readCredentials(w, r)
if !ok {
return
}
var id int64
var stored string
err := s.db.QueryRowContext(r.Context(), "SELECT id, password_hash FROM users WHERE username = ?", c.Username).Scan(&id, &stored)
found := err == nil
if err != nil && !errors.Is(err, sql.ErrNoRows) {
s.fail(w, r, err)
return
}
if !found {
stored = s.dummyHash
}
result := s.hasher.Verify(c.Password, stored)
if !found || !result.Valid {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid username or password"})
return
}
if result.NeedsRehash {
// Only now is the plaintext known to be right. Replace exactly the hash that was
// verified: if a password change landed in the meantime, no row matches.
hash, err := s.hasher.Hash(c.Password)
if err == nil {
_, err = s.db.ExecContext(r.Context(),
"UPDATE users SET password_hash = ? WHERE id = ? AND password_hash = ?", hash, id, stored)
}
if err != nil {
s.log.Error("rehash failed", "user", id, "err", err) // the login itself succeeded
}
}
writeJSON(w, http.StatusOK, map[string]string{"username": c.Username})
}With IMPL=fixed, 11 of 11 tests pass.
A fix that looks safe but is not
func (Wrong) Hash(password string) (string, error) {
salt := make([]byte, 16)
rand.Read(salt)
sum := sha256.Sum256(append(salt, password...))
return "sha256$" + hex.EncodeToString(salt) + "$" + hex.EncodeToString(sum[:]), nil
}With IMPL=wrong, 3 of 11 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: what a stolen copy of the users table gives an attacker, and how hashes upgrade.
func TestExploitANewHashIsArgon2idAtOrAboveTheOWASPMinimum(t *testing.T) {
f := newFixture(t)
expectStatus(t, f.register(t, "ivan", "ivan-has-a-long-passphrase"), http.StatusCreated)
expectMeetsPolicy(t, f.storedHash(t, "ivan"))
}
func TestExploitTheSamePasswordGivesEachUserADifferentHash(t *testing.T) {
f := newFixture(t)
f.register(t, "judy", legacyPassword)
f.register(t, "mallory", legacyPassword)
if f.storedHash(t, "judy") == f.storedHash(t, "mallory") {
t.Error("two users with the same password have the same hash")
}
}
func TestExploitALegacySHA256HashIsReplacedAfterASuccessfulLogin(t *testing.T) {
f := newFixture(t)
legacy := f.storedHash(t, "alice")
expectStatus(t, f.login(t, "alice", legacyPassword), http.StatusOK)
if f.storedHash(t, "alice") == legacy {
t.Fatal("the legacy SHA-256 hash is still stored")
}
expectMeetsPolicy(t, f.storedHash(t, "alice"))
expectStatus(t, f.login(t, "alice", legacyPassword), http.StatusOK) // the new hash verifies
}
func TestExploitAWrongPasswordIsRejectedAndLeavesTheLegacyHashAlone(t *testing.T) {
f := newFixture(t)
legacy := f.storedHash(t, "alice")
expectStatus(t, f.login(t, "alice", "correct horse battery stapler"), http.StatusUnauthorized)
if f.storedHash(t, "alice") != legacy {
t.Error("a wrong password changed the stored hash")
}
}
func TestExploitAHashWithWeakerParametersIsRehashedAfterLogin(t *testing.T) {
f := newFixture(t)
// 4 MiB and 3 passes: below every OWASP minimum configuration.
salt := make([]byte, 16)
rand.Read(salt)
key := argon2.IDKey([]byte(legacyPassword), salt, 3, 4096, 1, 32)
weak := fmt.Sprintf("$argon2id$v=19$m=4096,t=3,p=1$%s$%s",
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key))
f.setStoredHash(t, "bob", weak)
expectStatus(t, f.login(t, "bob", legacyPassword), http.StatusOK)
expectMeetsPolicy(t, f.storedHash(t, "bob"))
}- x/crypto's argon2 package has no PHC encoder, verifier or rehash check: the card writes about 40 lines for them. Parse strictly and bound the parameters you read back, because argon2.IDKey panics on zero passes or lanes and a corrupted row could ask for gigabytes of memory.
- bcrypt reads at most 72 bytes. x/crypto's bcrypt.GenerateFromPassword returns ErrPasswordTooLong above 72 bytes rather than truncating, which turns long passphrases into registration errors; Argon2id has no such limit, and NIST SP 800-63B-4 asks verifiers to accept at least 64 characters and never truncate.
- Compare keys with subtle.ConstantTimeCompare, not bytes.Equal or ==; it returns 0 at once only when the lengths differ, which here are fixed by the stored hash.
- Each Argon2id hash holds its memory (19 MiB at this setting) while it runs: about 22 ms of CPU each in this card's container. Bound concurrent logins, for example with a semaphore around Verify, and keep rate limiting, because hashing protects stolen hashes, not the login endpoint.
- Rehash only after a successful verify, never before: rehashing first lets anyone who knows a username set that user's password.
- A pepper or a wrapped legacy hash (Argon2id over the old SHA-256) helps against offline attacks only as defence in depth; the stored record still needs the algorithm and parameters so the policy can change later.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs password-hashing/go-nethttp does this for you, in Docker if you do not have Go).
- Look at real rows: accounts created or used since the change should hold strings that start with $argon2id$v=19$m=19456,t=2,p=1$ (or your stronger setting), and the count of rows matching ^[0-9a-f]{64}$ should fall as people log in.
- CodeQL's go/weak-sensitive-data-hashing is the query that targets this bug and it found every fast hash here; gosec and Semgrep look for MD5 and SHA-1 and found nothing. The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | go.lang.security.audit.md5-used-as-password.md5-used-as-password | No findings. Its sources are md5.New and md5.Sum only, and the weak-crypto rules look for MD5, SHA-1, DES and RC4, so the card's unsalted and salted SHA-256 pass them all; p/golang reported nothing either.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config r/<id> . |
| CodeQL | go/weak-sensitive-data-hashing | Five results, each a value named password reaching sha256.Sum256, which the query calls "not a computationally expensive hash function": Hash in impl/vulnerable.go, Hash and both Verify branches in impl/wrong.go, and the legacy branch of Verify in impl/fixed.go. The last is expected until no legacy rows remain; argon2.IDKey is not reported.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12; database built with go build ./... in the golang:1.27.2 image (query source read at github/codeql b5b8165) |
| gosec | G401Detect the usage of MD5 or SHA1 | No findings, and no other gosec rule fired: G401 and the G501 and G505 import blocklists cover MD5 and SHA-1, not SHA-256. An earlier draft of impl/fixed.go got a G115 (int to uint32 conversion) for the key length read from the stored hash; the parser now bounds it to 16 to 64 bytes first.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec ./... |
| govulncheck | govulncheck ./... and -scan module | No vulnerable code is called. The module scan lists GO-2026-5932 for golang.org/x/crypto 0.57.0: the x/crypto/openpgp package is unmaintained and has no fixed version. The card does not import openpgp.Checked: govulncheck 1.8.0 |
| Component | Version |
|---|---|
| Language | Go |
| Runtime | Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9 |
| Framework | Go standard library 1.27.2 (net/http, database/sql) with x/crypto 0.57.0 (argon2) |
| golang.org/x/crypto (argon2) | 0.57.0 |
| modernc.org/sqlite | 1.60.1 (pure Go, no cgo; SQLite 3.53.4) |
| gotest.tools/gotestsum (JUnit report, a go.mod tool) | 1.13.0 |
| golang.org/x/text (raised for gotestsum) | 0.42.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the password-hashing/go-nethttpfolder.
go mod download
go build ./... tool# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go mod download && go build ./... tool'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=fixed \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=vulnerable \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=wrong \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs password-hashing/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop 4.73.0 (Engine 29.4.3), linux/arm64
- Toolchain
- go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
- golang.org/x/crypto 0.57.0
- modernc.org/sqlite 1.60.1 (SQLite 3.53.4)
- gotestsum 1.13.0
- semgrep 1.180.0
- gosec 2.29.0
- govulncheck 1.8.0
- codeql CLI 2.27.1, codeql/go-queries 1.6.12
- Matrix
- fixed: 11 of 11 tests pass; vulnerable: 4 of 11 tests fail (4 exploit tests); wrong: 3 of 11 tests fail (3 exploit tests)
- Test results
- fixed 11/11 pass; vulnerable 4/11 fail (4 exploit tests); wrong 3/11 fail (3 exploit tests: the new hash, the legacy upgrade and the weaker-parameter rehash), with --network=none after install; the same on Go 1.26.9
- Dependency freshness
- golang.org/x/crypto 0.57.0 was published on 2026-09-08; 0.58.0 (2026-10-09) is under seven days old and fixes no advisory. modernc.org/sqlite 1.60.1 is from 2026-09-29 and gotestsum 1.13.0 from 2025-09-11; go.mod raises gotestsum's golang.org/x/text from 0.17.0 to 0.42.0 (2026-09-08) for GO-2026-5970. Go 1.27.2 (2026-10-08) is under seven days old but is a security release. govulncheck 1.8.0 found no vulnerable code called; see the scanner rules for GO-2026-5932.
- Hash cost
- 21.4 and 24.0 ms per Argon2id hash at m=19456, t=2, p=1 (mean of 20 after warm-up, two runs, linux/arm64 container on an Apple M5 Max)
- Notes
- Eleven tests, like the Java and C# cards: the Node.js and Python cards add a test that spies on the constant-time primitive for the legacy comparison, and Go cannot replace a standard library function at run time. Oversized passwords get 400, as in the Flask card.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Password Storage Cheat Sheet (Argon2id minimum configuration)
- golang.org/x/crypto/argon2 package documentation (IDKey)
- golang.org/x/crypto/bcrypt package documentation (ErrPasswordTooLong)
- C2SP: PHC string format (B64 encoding and Argon2 parameters)
- RFC 9106: Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications
- NIST SP 800-63B-4: Password verifiers
- Go standard library: crypto/subtle ConstantTimeCompare
- CodeQL: go/weak-sensitive-data-hashing
- Go vulnerability GO-2026-5932 (x/crypto/openpgp unmaintained)
- gosec 2.29.0 rules (G401, G501, G505)
Attack patterns: