Software Securityhome
Fix a findingSearch
Fix card · A04:2025 Cryptographic Failures

Fix weak password hashing in Go and net/http

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • 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)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks7 cards
Download the project ZIP, 27 KB
On this page

Steps

  1. 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.
  2. 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).
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.goGo
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

Fixedimpl/fixed.goGo
// 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

Fixedimpl/fixed.goGo
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

Fixedapp.goGo
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

Wrong fiximpl/wrong.goGo
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 regression test

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.

Testapp_test.goGo
// 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"))
}

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix weak password hashing in Go and net/http
ToolRuleWhat it found here
Semgrepgo.lang.security.audit.md5-used-as-password.md5-used-as-passwordNo 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> .
CodeQLgo/weak-sensitive-data-hashingFive 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)
gosecG401Detect the usage of MD5 or SHA1No 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 ./...
govulncheckgovulncheck ./... and -scan moduleNo 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguageGo
RuntimeGo 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
FrameworkGo 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/sqlite1.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

Run the tests

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.

Shell
go mod download
go build ./... tool
Shell
# 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.

Shell
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'
Shell
# 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.

Download the project ZIP, 27 KB

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

  1. OWASP Password Storage Cheat Sheet (Argon2id minimum configuration)
  2. golang.org/x/crypto/argon2 package documentation (IDKey)
  3. golang.org/x/crypto/bcrypt package documentation (ErrPasswordTooLong)
  4. C2SP: PHC string format (B64 encoding and Argon2 parameters)
  5. RFC 9106: Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications
  6. NIST SP 800-63B-4: Password verifiers
  7. Go standard library: crypto/subtle ConstantTimeCompare
  8. CodeQL: go/weak-sensitive-data-hashing
  9. Go vulnerability GO-2026-5932 (x/crypto/openpgp unmaintained)
  10. gosec 2.29.0 rules (G401, G501, G505)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.