Argon2id Parameters That Actually Work in 2026: A Production Guide

Everyone knows you shouldn’t store plaintext passwords. Most developers know bcrypt is "the correct answer." The problem is, bcrypt was designed in 1999, and in 2026, attackers have cheap GPU clusters and FPGAs that eat bcrypt hashes for breakfast. Argon2id is the answer, but most articles either repeat the same vague OWASP table without context, or they benchmark parameters on a cloud instance you don’t own and can’t reproduce.

This article is different. I’ll show you exactly how to pick parameters for your actual hardware, explain what each knob does and why it matters, walk through real implementation code, and cover the gotchas that cost production systems dearly.

The official spec lives in RFC 9106 and the reference implementation is on GitHub.


Why Argon2id and Not bcrypt

bcrypt’s fundamental flaw is that it’s memory-hard only up to 4KB. That’s a fixed ceiling, set in 1999. Modern GPUs ship with tens of gigabytes of fast VRAM. bcrypt’s 4KB footprint means an attacker can parallelize tens of thousands of hash attempts simultaneously across GPU cores.

scrypt improved on this by making the memory requirement configurable, but its sequential memory access pattern is friendly to cache optimizations that attackers exploit.

Argon2 won the Password Hashing Competition in 2015 after years of cryptanalysis. It comes in three variants:

  • Argon2d — maximizes GPU resistance through data-dependent memory access. Vulnerable to side-channel attacks, so don’t use it for passwords.
  • Argon2i — uses data-independent memory access (side-channel safe), but weaker against GPU attacks.
  • Argon2id — hybrid. First half of passes use Argon2i (side-channel safety), second half use Argon2d (GPU resistance). This is the one you want.

If you’re starting a new project in 2026, Argon2id is non-negotiable. If you’re running bcrypt, I’ll cover migration later.


The Three Parameters You Need to Understand

Argon2id has three primary tuning parameters. Get these wrong and you’re either leaving security on the table or DoS-ing your own login endpoint.

Memory Cost (m)

Measured in kibibytes. This is the most important parameter. It determines how much RAM must be allocated per hash operation. An attacker trying to crack your hashes needs this much memory per parallel attempt. If you set m=65536 (64 MiB), an attacker can only run ~256 parallel cracks on a GPU with 16 GiB VRAM, instead of millions.

Memory cost hurts attackers far more than time cost does, because VRAM is expensive and finite. CPU time is cheap to parallelize; memory is not.

Time Cost (t)

Number of passes over the memory. Increasing t linearly increases CPU time without increasing memory pressure. Think of it as a multiplier on top of the memory cost. It’s useful for fine-tuning your target hash duration without blowing up RAM usage.

Parallelism (p)

How many threads Argon2 will use internally. This sets the minimum memory required to p * m conceptually (each lane gets its own memory block). In practice, for password hashing, setting p=1 is common. Higher values don’t give your users any security benefit — they give attackers the ability to use the parallelism against you. The exception is if you’re doing bulk operations and have spare cores to burn.


What the Benchmarks Actually Look Like

Forget the OWASP table for a moment. The real question is: how long does one hash take on your machine?

Here’s a dead-simple benchmark script. Run this on your production hardware, or as close as you can get.

#!/bin/bash
# benchmark-argon2.sh — run before deciding on parameters

# Install: apt install argon2

echo "=== Argon2id Benchmark ==="
echo "Each test hashes 'password' 10 times and reports average."
echo ""

test_params() {
  local label=$1
  local m=$2  # memory in KiB
  local t=$3  # iterations
  local p=$4  # parallelism

  local start=$(date +%s%3N)
  for i in $(seq 1 10); do
    echo -n "password" | argon2 "saltysalt123456!" -id -m $m -t $t -p $p -l 32 > /dev/null 2>&1
  done
  local end=$(date +%s%3N)
  local avg=$(( (end - start) / 10 ))
  echo "$label: ~${avg}ms per hash"
}

# m values are log2 of KiB: m=17 → 131072 KiB (128 MiB), m=16 → 65536 KiB (64 MiB)
# argon2 CLI takes log2 for -m flag, not raw KiB — adjust accordingly
# For the CLI: -m 16 = 64 MiB, -m 15 = 32 MiB, -m 14 = 16 MiB

test_params "OWASP minimum   (m=19MiB, t=2, p=1)" 14 2 1
test_params "libsodium GOOD  (m=64MiB, t=2, p=1)" 16 2 1
test_params "High security   (m=128MiB, t=3, p=4)" 17 3 4
test_params "Paranoid        (m=256MiB, t=4, p=4)" 18 4 4

Note: The argon2 CLI uses -m as log₂ of KiB (-m 16 = 64 MiB). Most libraries use raw KiB directly (m=65536). Don’t mix them up.

Target timing guidelines:

Use case Target hash time
Interactive login (web, mobile) 500ms – 1000ms
API authentication (per-request) 100ms – 300ms
Password manager master key 2000ms – 5000ms
Bulk import / migration As fast as safe

Based on RFC 9106, OWASP 2025 guidelines, and real-world testing on typical VPS hardware (2-4 vCores, 4-8 GB RAM):

Minimum acceptable (shared hosting, resource-constrained):

m = 19456   (19 MiB)
t = 2
p = 1

Standard production (recommended for most web apps):

m = 65536   (64 MiB)
t = 3
p = 2

High-value accounts (admin panels, financial apps):

m = 131072  (128 MiB)
t = 4
p = 4

Always benchmark these on your own hardware. A $5 VPS and a bare-metal server with 128 GB RAM behave completely differently under memory pressure.


Implementation — The Right Way

Python with argon2-cffi

This is the most battle-tested Python binding. It wraps libargon2 properly and exposes a clean API.

pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError

# Configure once at module level
ph = PasswordHasher(
    time_cost=3,        # t: number of passes
    memory_cost=65536,  # m: 64 MiB in KiB
    parallelism=2,      # p: threads
    hash_len=32,        # output length in bytes
    salt_len=16,        # random salt length in bytes
)

def hash_password(plaintext: str) -> str:
    return ph.hash(plaintext)

def verify_password(hash: str, plaintext: str) -> bool:
    try:
        return ph.verify(hash, plaintext)
    except VerifyMismatchError:
        return False
    except (VerificationError, InvalidHashError):
        # Corrupted hash or wrong format
        return False

def needs_rehash(hash: str) -> bool:
    # Returns True if hash was made with outdated parameters
    return ph.check_needs_rehash(hash)

The check_needs_rehash() method is critical. When you update your parameters, call this on every successful login and silently rehash in the background. Users never notice; their passwords get stronger over time.

Go with golang.org/x/crypto

package auth

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

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

type Params struct {
    Memory      uint32
    Iterations  uint32
    Parallelism uint8
    SaltLength  uint32
    KeyLength   uint32
}

var DefaultParams = &Params{
    Memory:      64 * 1024, // 64 MiB
    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)

    // Encode as PHC string format: $argon2id$v=19$m=...,t=...,p=...$salt$hash
    b64Salt := base64.RawStdEncoding.EncodeToString(salt)
    b64Hash := base64.RawStdEncoding.EncodeToString(hash)

    encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
        argon2.Version, p.Memory, p.Iterations, p.Parallelism, b64Salt, b64Hash)

    return encoded, nil
}

func VerifyPassword(password, encodedHash string) (bool, error) {
    p, salt, hash, err := decodeHash(encodedHash)
    if err != nil {
        return false, err
    }

    otherHash := argon2.IDKey([]byte(password), salt, p.Iterations, p.Memory, p.Parallelism, p.KeyLength)

    // Constant-time comparison — never use == on hashes
    if subtle.ConstantTimeCompare(hash, otherHash) == 1 {
        return true, nil
    }
    return false, nil
}

func decodeHash(encodedHash string) (*Params, []byte, []byte, error) {
    parts := strings.Split(encodedHash, "$")
    if len(parts) != 6 {
        return nil, nil, nil, errors.New("invalid hash format")
    }

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

    p := &Params{}
    if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.Memory, &p.Iterations, &p.Parallelism); err != nil {
        return nil, nil, nil, err
    }

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

    hash, err := base64.RawStdEncoding.DecodeString(parts[5])
    if err != nil {
        return nil, nil, nil, err
    }
    p.KeyLength = uint32(len(hash))

    return p, salt, hash, nil
}

Node.js

npm install argon2
const argon2 = require('argon2');

const ARGON2_OPTIONS = {
  type: argon2.argon2id,
  memoryCost: 65536,   // 64 MiB in KiB
  timeCost: 3,
  parallelism: 2,
  hashLength: 32,
  saltLength: 16,
};

async function hashPassword(plaintext) {
  return argon2.hash(plaintext, ARGON2_OPTIONS);
}

async function verifyPassword(hash, plaintext) {
  try {
    return await argon2.verify(hash, plaintext);
  } catch {
    return false;
  }
}

// Check if a hash needs upgrading to current params
async function needsRehash(hash) {
  return argon2.needsRehash(hash, ARGON2_OPTIONS);
}

Migrating from bcrypt

Don’t try to rehash all passwords at once. You don’t have the plaintext — you can’t. The correct strategy is lazy migration:

  1. Add an algo column to your users table (or detect the prefix: bcrypt hashes start with $2b$, Argon2id hashes start with $argon2id$).
  2. On successful login, check which algorithm was used. If it’s bcrypt, verify with bcrypt, then immediately rehash with Argon2id and store the new hash.
  3. After enough time (monitor what percentage of accounts have migrated), force-expire all remaining bcrypt sessions and make those users reset their password.
def login(username: str, plaintext: str) -> bool:
    user = db.get_user(username)
    if not user:
        # Dummy hash to prevent timing attacks
        ph.hash("dummy_to_waste_time")
        return False

    if user.hash.startswith("$2b$"):
        # Legacy bcrypt
        import bcrypt
        if not bcrypt.checkpw(plaintext.encode(), user.hash.encode()):
            return False
        # Upgrade silently
        user.hash = ph.hash(plaintext)
        db.update_hash(user.id, user.hash)
        return True

    return verify_password(user.hash, plaintext)

Gotchas

Memory cost applies per concurrent request. If your app serves 100 simultaneous login attempts with m=64MiB, that’s 6.4 GB of RAM consumed just for hashing. Plan accordingly. Rate-limit login endpoints aggressively — 5-10 requests per IP per minute is generous.

Never roll your own salt. Let the library generate the salt. Don’t use username + timestamp as a salt. Don’t reuse salts across users. Random, per-password, 16+ bytes minimum.

Use constant-time comparison. In Go I showed subtle.ConstantTimeCompare. In Python, argon2-cffi handles this internally. In Node, the argon2 package does too. If you ever write a raw byte comparison on hash values, you’ve introduced a timing side-channel.

Don’t pepper wrong. A pepper is a server-side secret concatenated with the password before hashing. It adds a layer of protection if the database is leaked but the application server isn’t. The pepper must be stored separately (env variable, secrets manager — never in the DB). The gotcha: if you pepper after hashing, you’ve wasted it. Pepper goes before hashing: hash(pepper + password). Also, treat pepper rotation as a migration event — you’ll need to rehash on login if you rotate the pepper.

The argon2 CLI’s -m flag is log₂, not raw KiB. -m 16 = 2¹⁶ KiB = 64 MiB. Most libraries take raw KiB (65536). Mixing these up is a silent bug that results in wildly wrong memory usage.

Parallelism above your core count wastes time. Setting p=8 on a 2-core VPS doesn’t make hashing 4x harder for attackers — it just makes your server slower by context-switching. Match p to the cores you want to dedicate to auth, typically 1-2 on shared infrastructure.

Test your container memory limits. If you’re running in Kubernetes or Docker with a memory limit lower than m * max_concurrent_logins, the OOM killer will visit you at 3am. Either set limits high enough or rate-limit at the ingress level.


Production Checklist

Before shipping:

  • Benchmark on production-equivalent hardware, not your laptop
  • Rate-limit /login at the load balancer or ingress — not just in application code
  • Implement check_needs_rehash / needsRehash and silently upgrade on login
  • Store the full PHC string format ($argon2id$v=19$...) — don’t strip the parameters from the hash
  • Pepper stored in secrets manager, not in the database or committed to git
  • Verify you’re using argon2id, not argon2i or argon2d
  • Constant-time comparison everywhere hashes are compared
  • Hash the password in a separate goroutine/thread with a timeout — a slow hash shouldn’t block your entire web server
  • Log failed login attempts (without the password) for anomaly detection
  • Account lockout or progressive delay after N failed attempts

One Last Thing

The parameters in this article will be "just right" for most web applications in 2026. But hardware gets faster every year. Revisit your parameters annually. When a hash that used to take 700ms on your server now takes 200ms, bump m or t and schedule a lazy rehash migration.

Password hashing is one of the few places in security where "good enough today" literally becomes "not good enough in three years." The PHC string format stores parameters with every hash specifically so you can upgrade without invalidating existing hashes. Use that feature.

If you benchmark, test, and keep parameters current, Argon2id will protect your users’ passwords for a very long time. That’s more than most production systems today can claim.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646