Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Go

Published:

Tags:
  • go
  • security
  • cryptography
  • password-hashing
  • argon2

Argon2id in Go: Picking Parameters You Can Actually Defend

There's a specific moment in most projects where someone pastes an Argon2id call into the codebase with m=65536, t=3, p=2 or similar, taken verbatim from a blog post or Stack Overflow answer, and nobody in the room can say why those particular numbers were chosen. It works. It passes review. Six months later someone asks "why 64 MiB?" in an incident retro and the honest answer is "a website said so." That's not a great place to be when the whole point of the exercise is defensibility.

Argon2id parameters aren't like most configuration values. They encode an actual claim about how expensive it is to brute-force your password hashes, on your hardware, under your load. Getting them from a cheat sheet without understanding what they cost you is exactly the kind of thing that looks fine until it isn't.

Why "id", and what the knobs actually buy you

Argon2 won the Password Hashing Competition in 2015 and comes in three variants. Argon2d maximises resistance to GPU cracking by making memory access data-dependent, which also makes it vulnerable to side-channel timing attacks. Argon2i uses data-independent access, which is safer against side channels but weaker against GPU/ASIC attackers. Argon2id is a hybrid: data-independent access for the first half of the first pass, data-dependent for the rest. RFC 9106 recommends Argon2id as the default choice for password hashing unless you have a specific reason to pick one of the others, and that's also what golang.org/x/crypto/argon2 exposes as argon2.IDKey.

Four parameters actually matter:

  • Memory cost (m), in KiB. This is Argon2's headline feature: an attacker trying to crack your hashes offline needs this much RAM per guess, per parallel attempt. It's what makes GPU and ASIC cracking rigs expensive to build, because they're built around cheap compute and comparatively scarce fast memory.
  • Time cost (t), the number of passes over that memory. Increasing this multiplies the CPU work without changing the memory footprint.
  • Parallelism (p), the number of independent lanes. This scales the algorithm to use multiple cores, but note that it multiplies memory usage roughly linearly too, so p=4 with m=64 MiB genuinely wants 256 MiB of working memory in practice depending on implementation details.
  • Salt and output length. A 16-byte (128-bit) random salt per hash is the standard recommendation, and a 32-byte (256-bit) output is plenty for a derived key used as a password verifier.

The mistake people make isn't picking a wrong value for any one of these, it's treating them as independent when they trade off against each other, and against the actual hardware the hashing runs on.

Two authoritative answers that disagree

This is the bit that trips people up: the two most commonly cited sources for Argon2 parameters give genuinely different numbers, because they're solving different problems.

RFC 9106 gives two profiles. The first, meant for general-purpose password hashing on a dedicated backend with generous resources, uses t=1, p=4, m=2^21 KiB (2 GiB), targeting roughly half a second on a 2 GHz, 4-core machine. The second, for constrained environments, uses t=3, p=4, m=2^16 KiB (64 MiB).

The OWASP Password Storage Cheat Sheet, aimed squarely at typical web application login flows where a server might be hashing dozens of passwords concurrently on shared infrastructure, gives much smaller minimums: m=19456 KiB (19 MiB), t=2, p=1, with an alternative of m=47104 KiB (46 MiB), t=1, p=1 if you can spare more memory.

Neither is wrong. RFC 9106's headline profile assumes you can dedicate 2 GiB of RAM per concurrent hash operation, which is a reasonable assumption for, say, a single dedicated authentication service handling one login at a time, and a terrible assumption for a shared web backend under a login-heavy campaign where twenty requests landing in the same second would need 40 GiB just to hash passwords. OWASP's numbers are calibrated for exactly that shared, bursty, memory-constrained reality. The right answer for your service is somewhere on that spectrum, and picking it means knowing which constraint actually binds for you: attacker cost, or your own server's memory budget under concurrent load.

Benchmark on the hardware that will actually run this

Rather than importing someone else's numbers, measure. Pick a target latency for a single hash (200-500ms is a common range for interactive login, more if this only runs on a background worker), then tune memory and iterations on the actual instance type you'll deploy to, not your laptop.

package main

import (
	"fmt"
	"time"

	"golang.org/x/crypto/argon2"
)

func main() {
	password := []byte("correct horse battery staple")
	salt := make([]byte, 16)

	for _, m := range []uint32{19 * 1024, 32 * 1024, 64 * 1024, 128 * 1024} {
		start := time.Now()
		argon2.IDKey(password, salt, 2, m, 2, 32)
		fmt.Printf("m=%d KiB t=2 p=2: %s\n", m, time.Since(start))
	}
}

Run that under something close to your expected concurrency (a handful of goroutines calling it simultaneously, not one at a time) so you can see how memory contention and CPU scheduling affect real latency, not just the single-threaded number. If your target machine has 4 cores and 4 GiB of RAM and you expect up to 10 concurrent logins, RFC 9106's 2 GiB profile is simply off the table regardless of how attractive it looks on paper.

An implementation that stores its own parameters

Whatever you land on today, you'll want to change it in a year or two as hardware gets cheaper for attackers. That means the stored hash needs to carry its own parameters, in the same style as the PHC string format the reference argon2 CLI uses, so old hashes stay verifiable after you ratchet up the cost for new ones.

package main

import (
	"crypto/rand"
	"crypto/subtle"
	"encoding/base64"
	"errors"
	"fmt"
	"strings"

	"golang.org/x/crypto/argon2"
)

type params struct {
	memory      uint32 // KiB
	iterations  uint32
	parallelism uint8
	saltLength  uint32
	keyLength   uint32
}

var defaultParams = params{
	memory:      64 * 1024, // 64 MiB, chosen after benchmarking on prod-like hardware
	iterations:  3,
	parallelism: 2,
	saltLength:  16,
	keyLength:   32,
}

func hashPassword(password string, p params) (string, error) {
	salt := make([]byte, p.saltLength)
	if _, err := rand.Read(salt); err != nil {
		return "", err
	}

	hash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)

	encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version,
		p.memory, p.iterations, p.parallelism,
		base64.RawStdEncoding.EncodeToString(salt),
		base64.RawStdEncoding.EncodeToString(hash),
	)
	return encoded, nil
}

func verifyPassword(password, encoded string) (bool, error) {
	parts := strings.Split(encoded, "$")
	if len(parts) != 6 || parts[1] != "argon2id" {
		return false, errors.New("invalid hash format")
	}

	var version int
	if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
		return false, err
	}
	if version != argon2.Version {
		return false, errors.New("incompatible argon2 version")
	}

	var p params
	if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism); err != nil {
		return false, err
	}

	salt, err := base64.RawStdEncoding.DecodeString(parts[4])
	if err != nil {
		return false, err
	}

	want, err := base64.RawStdEncoding.DecodeString(parts[5])
	if err != nil {
		return false, err
	}

	got := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, uint32(len(want)))

	return subtle.ConstantTimeCompare(got, want) == 1, nil
}

The parameters travel with the hash, so verification always uses whatever settings the hash was created with, and you're free to bump defaultParams for new signups or password changes without invalidating anything already in the database. It's also worth checking on login whether a hash's stored parameters are weaker than your current defaults, and if so, re-hashing the password with the new parameters once you have it in plaintext during a successful login. That's the actual mechanism by which parameters get to "ratchet up" over the years rather than staying frozen at whatever was reasonable in 2024.

The catch nobody puts in the marketing

Memory-hardness is a genuine security property, and it's also an attack surface you're handing to anyone who can trigger password hashing on demand, which on most sites means anyone who can hit the login endpoint. If your Argon2id parameters cost 64 MiB per hash and your authentication service runs in a container with a 512 MiB limit, eight concurrent login attempts, malicious or not, will get you OOM-killed. This is not a theoretical concern; login endpoints are unauthenticated by definition, and a modest, unremarkable burst of traffic is enough to turn your carefully chosen memory-hardness into a self-inflicted denial of service.

Rate limit login attempts per IP or account before they reach the hashing code, size your container memory limits with your actual peak concurrent hash count in mind (not the average), and consider queuing or rejecting hash requests once you're near that ceiling rather than letting the scheduler thrash. None of this shows up in a benchmark that runs one hash at a time on an idle machine, which is exactly why so many deployments discover it in production instead.

For what it's worth, if none of this appeals and you'd rather not think about memory budgets at all, bcrypt is still a perfectly defensible choice for most web applications: no memory-hardness to reason about, decades of scrutiny, and a work factor that's trivial to reason about under load. Argon2id buys you real resistance against custom cracking hardware, but only if you've actually worked out what it costs your own infrastructure to run it. A parameter set you can't explain in one sentence, in terms of "this is what it costs an attacker" and "this is what it costs me," isn't one you should be shipping.