Articles / Go

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

Picking Argon2id Parameters You Can Actually Defend

Somebody pastes m=65536, t=3, p=2 into an Argon2id call, straight from a blog post or a Stack Overflow answer, and nobody in the room can say why those numbers. 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 an awkward place to be when the whole point of the exercise is defensibility.

Argon2id parameters aren't ordinary configuration values. They encode a claim about how expensive it is to brute-force your hashes, on your hardware, under your load. Lifting them from a cheat sheet without knowing what they cost you looks fine until it isn't.

Why "id", and what each knob buys 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 leaves it open to side-channel timing attacks.
  • Argon2i uses data-independent access: safer against side channels, weaker against GPU/ASIC attackers.
  • Argon2id is a hybrid, data-independent for the first half of the first pass and data-dependent for the rest.

RFC 9106 recommends Argon2id as the default for password hashing unless you have a specific reason to pick another. It'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 cracking 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 usual mistake isn't a wrong value for any one of these. It's treating them as independent, when they trade off against each other and against the hardware the hashing runs on.

The two authoritative sources disagree, and both are right

This is the bit that trips people up. The two most-cited sources give genuinely different numbers, because they're solving different problems.

RFC 9106 gives two profiles:

  • For general-purpose password hashing on a dedicated backend with generous resources: t=1, p=4, m=2^21 KiB (2 GiB), targeting roughly half a second on a 2 GHz, 4-core machine.
  • For constrained environments: t=3, p=4, m=2^16 KiB (64 MiB).

The OWASP Password Storage Cheat Sheet aims squarely at typical web login flows, where a server might hash dozens of passwords at once on shared infrastructure. Its minimums are much smaller:

  • m=19456 KiB (19 MiB), t=2, p=1;
  • or, if you can spare more memory, m=47104 KiB (46 MiB), t=1, p=1.

Neither is wrong. RFC 9106's headline profile assumes you can dedicate 2 GiB of RAM per concurrent hash. That's reasonable for a single dedicated authentication service handling one login at a time.

It's a terrible assumption for a shared web backend during 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.

Your answer sits somewhere on that spectrum. Picking it means knowing which constraint actually binds for you: attacker cost, or your own server's memory budget under concurrent load.

Measure on the machine that will run it

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 it under realistic concurrency, not one hash at a time.

A handful of goroutines calling it simultaneously shows how memory contention and CPU scheduling affect real latency, not just the single-threaded number. If the target has 4 cores, 4 GiB of RAM and up to 10 concurrent logins, RFC 9106's 2 GiB profile is simply off the table, however attractive it looks on paper.

Store the parameters with the hash

Whatever you land on today, you'll want to change it in a year or two as hardware gets cheaper for attackers. So the stored hash needs to carry its own parameters, in the style of the PHC string format the reference argon2 CLI uses. 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
}

Verification always uses the settings the hash was made with.

The parameters travel with the hash, so you're free to bump defaultParams for new signups or password changes without invalidating anything already in the database.

Quick detour, because this is the clever bit: on login, check whether a hash's stored parameters are weaker than your current defaults. If so, re-hash the password once you have it in plaintext during a successful login. That's the actual mechanism by which parameters "ratchet up" over the years rather than staying frozen at whatever was reasonable in 2024.

Memory-hardness is also a denial-of-service lever

Memory-hardness is a genuine security property. It's also an attack surface you hand to anyone who can trigger password hashing on demand, which on most sites means anyone who can hit the login endpoint.

If each hash costs 64 MiB and your authentication service runs in a container with a 512 MiB limit, eight concurrent login attempts will get you OOM-killed, malicious or not. This isn't theoretical: login endpoints are unauthenticated by definition, and a modest, unremarkable burst of traffic turns your carefully chosen memory-hardness into a self-inflicted denial of service.

What to do about it:

  • Rate limit login attempts per IP or account before they reach the hashing code.
  • Size container memory limits for your actual peak concurrent hash count, not the average.
  • Queue or reject 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. That's exactly why so many deployments discover it in production instead.

Or just use bcrypt, honestly

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