Articles / Security

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

Why Your Password Hash Is Only as Slow as Its Cheapest Parameter

You can crank one cost setting to the moon and still end up with a cheap hash. Attackers do not pay for the number you are proud of; they pay for whichever dimension is least expensive on their hardware. This post is about spotting that dimension.

The attacker optimises the cheap axis

A password hash is a bill the attacker pays per guess. Your job is to make that bill large on the hardware they actually own, which is usually GPUs or ASICs, not your web server.

Those devices have huge numbers of cores, but little fast memory per core. So the question for any parameter is: does raising it cost a GPU as much as it costs me? If not, it is decoration.

Three schemes, three different cheap axes

  • PBKDF2 has one knob, the iteration count, and almost no memory use. A GPU runs thousands of copies side by side, so iterations only slow it linearly.
  • bcrypt has a cost factor that doubles the work each step. It needs about 4 KiB of state, which is small enough that modern hardware copes, and it also ignores bytes past 72 of the input.
  • Argon2id has memory, passes and lanes. Only some of these hurt an attacker.

Argon2 is the interesting one because three knobs look like three layers of defence. They are not equal.

What each Argon2 parameter really buys you

Memory is the expensive one for an attacker. Each guess needs its own block of RAM, so a GPU with a fixed memory budget can run far fewer guesses in parallel. That is the whole point of the design.

Passes (the time cost) re-walk that memory. Raising them multiplies the work, and it also closes a loophole: with a single pass, an attacker can sometimes store less memory and recompute the missing parts, trading time for space at a modest penalty. More passes make that trade worse for them.

Lanes (parallelism) are the sneaky one. They split your memory into independent chunks that can be computed on several threads. That makes your login faster in wall-clock terms, but the total work is unchanged. An attacker running one guess per core just computes the lanes one after another.

So raising lanes does not make the attacker pay more. It lets you spend the same cost in less time, which is useful, but it is not a defence knob.

The unit mistake that quietly guts it

Quick detour, because this one is daft. In Go, argon2.IDKey takes memory in KiB, not bytes and not MiB. Writing 64 thinking "64 MiB" gives you 64 KiB.

package main

import (
	"crypto/rand"
	"fmt"
	"time"

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

func timeIt(label string, passes, memKiB uint32, lanes uint8) {
	salt := make([]byte, 16)
	if _, err := rand.Read(salt); err != nil {
		panic(err)
	}
	start := time.Now()
	argon2.IDKey([]byte("correct horse"), salt, passes, memKiB, lanes, 32)
	fmt.Printf("%-28s %v\n", label, time.Since(start))
}

func main() {
	timeIt("t=3 m=64 (KiB, oops)", 3, 64, 4)
	timeIt("t=3 m=64*1024 (64 MiB)", 3, 64*1024, 4)
	timeIt("t=1 m=64*1024, p=1", 1, 64*1024, 1)
	timeIt("t=1 m=64*1024, p=4", 1, 64*1024, 4)
}

The first line finishes almost instantly and looks "fast, therefore fine" in a code review. It is not fine: the memory axis has collapsed to a few KiB, so a GPU barely notices it.

How to read the output

Run it on a machine with several cores. The two lines with different lane counts should differ in time on the same memory and passes. That gap is the lane effect: less wall-clock for you, the same total work for an attacker.

It is a rough check, not a benchmark. Timings vary with CPU, load and whether you are in a container with a CPU quota, so run it on the target environment.

A sensible order for tuning

  1. Fix the memory first, as high as your server can afford per concurrent login.
  2. Set passes to at least 2 or 3 so the recompute shortcut is not attractive.
  3. Pick lanes to match the cores you can spare per login, mainly to cut latency.
  4. Measure latency on production hardware and adjust memory or passes to hit your budget.

RFC 9106 gives concrete recommended parameter sets, including a low-memory option with more passes for constrained servers. Start from those rather than from a number you half remember.

The wider habit

Whenever a scheme has several cost settings, ask which one an attacker with specialised hardware gets for free. For PBKDF2 it is memory, which does not exist. For Argon2 it is lanes. For any of them it is a parameter that got clamped, defaulted or passed in the wrong unit.

Also check what your library does with out-of-range values. Some silently raise tiny memory settings to a minimum rather than rejecting them, which hides the mistake. A unit test that asserts your configured memory is at least a stated floor costs three lines and catches this class of error.