How to protect user credentials with Argon2id: parameters, code and migration from bcrypt

LoginMaster team

To protect user credentials with Argon2 you store only the Argon2id hash of the password, with a random 16-byte salt per password and cost parameters at least equal to OWASP's (for example m=19 MiB, t=2, p=1), rehash on login when the parameters go up and cap the concurrency of the login endpoint. LoginMaster applies Argon2 with a split-salt scheme that separates the cryptographic components between the customer's Tenant and the Cloud: no single store, not even the provider's, is enough to reconstruct a credential.

Which parameters to use: OWASP and RFC 9106 compared

Argon2id has three cost parameters: memory (m, in KiB), iterations (t) and parallelism (p). Two authoritative sources give different guidance because they have different goals: the OWASP Password Storage Cheat Sheet sets a sustainable minimum for shared web servers, while RFC 9106 describes recommended configurations when memory is not a constraint.

SourceMemory (m)Iterations (t)Parallelism (p)When to use it
OWASP47104 KiB (46 MiB)11Servers with enough RAM: the minimum with more memory
OWASP19456 KiB (19 MiB)21Typical starting point for web applications
OWASP12288 KiB (12 MiB)31Scarce memory, offset by more passes
OWASP9216 KiB (9 MiB)41Very small containers
OWASP7168 KiB (7 MiB)51Lower bound of the OWASP table
RFC 9106, first recommendation2 GiB14Dedicated environments with plenty of memory
RFC 9106, second recommendation64 MiB34When 2 GiB is not sustainable

The five OWASP rows are considered equivalent: they trade memory for iterations. Both RFC 9106 recommendations use a 128-bit (16-byte) salt and a 256-bit (32-byte) tag, the same values used in the examples below. For a web login with many simultaneous sign-ins the 2 GiB RFC configuration is almost always impractical; the 64 MiB one is a good target after calibration.

Calibrating the parameters on your hardware

The OWASP values are a floor, not a target. The correct procedure is to measure on production servers (or identical machines) and raise memory while two budgets still hold:

  1. 1Latency budget: a single hash must stay within a few hundred milliseconds, the threshold beyond which users perceive login as slow.
  2. 2Memory budget: memory per hash × peak concurrent logins must fit in the RAM you can dedicate to authentication. With m=46 MiB, 100 simultaneous logins take about 4.6 GiB.
  3. 3Raise memory (m) first, then iterations (t): memory is what makes GPU attacks expensive.
  4. 4Repeat the measurement on every instance or runtime change and record the chosen parameters in your security documentation: an ISO 27001 auditor will ask for them.
Quick benchmark in Node.js
import argon2 from "argon2";

const candidates = [
  { memoryCost: 19456, timeCost: 2 },
  { memoryCost: 47104, timeCost: 1 },
  { memoryCost: 65536, timeCost: 3 },
];

for (const c of candidates) {
  const start = performance.now();
  for (let i = 0; i < 20; i++) {
    await argon2.hash("benchmark-password", {
      type: argon2.argon2id,
      parallelism: 1,
      ...c,
    });
  }
  const ms = (performance.now() - start) / 20;
  console.log(c, ms.toFixed(0) + " ms per hash");
}

The PHC format: what to store in the database

Mature libraries return a string in PHC format containing algorithm, version, parameters, salt and hash. Storing this string, not just the hash, is what lets you change parameters later without breaking existing accounts.

Anatomy of an Argon2id PHC string
$argon2id$v=19$m=19456,t=2,p=1$<salt base64>$<hash base64>
 |         |    |                |             |
 |         |    |                |             +-- tag: 32 bytes
 |         |    |                +-- random salt: 16 bytes, one per password
 |         |    +-- cost parameters used for THIS hash
 |         +-- algorithm version (19 = 0x13)
 +-- variant
  • A text column of at least 128 characters: leave room for higher parameters in the future.
  • The salt is not a secret: it makes every hash unique and defeats rainbow tables. Never reuse it across users.
  • No separate column for the parameters: they are already in the string, and verification reads them from there.

Code: hash, verify and rehash

Node.js with the argon2 package

Node.js (npm: argon2)
import argon2 from "argon2";

const PARAMS = {
  type: argon2.argon2id,
  memoryCost: 19456, // KiB
  timeCost: 2,
  parallelism: 1,
  hashLength: 32,
};

export async function hashPassword(password) {
  // the random 16-byte salt is generated by the library
  return argon2.hash(password, PARAMS);
}

export async function login(user, password) {
  const ok = await argon2.verify(user.passwordHash, password);
  if (!ok) return false;

  // parameters raised after the hash was created? rehash now
  if (argon2.needsRehash(user.passwordHash, PARAMS)) {
    user.passwordHash = await argon2.hash(password, PARAMS);
    await saveUser(user);
  }
  return true;
}

.NET with Konscious.Security.Cryptography

Konscious returns raw bytes, not a PHC string: you store salt and parameters yourself. The comparison must be constant-time.

C# (.NET 6+, NuGet: Konscious.Security.Cryptography.Argon2)
using System.Security.Cryptography;
using System.Text;
using Konscious.Security.Cryptography;

public static class PasswordHasher
{
    const int MemoryKiB = 19456, Iterations = 2, Parallelism = 1;

    public static (byte[] Salt, byte[] Hash) Hash(string password)
    {
        byte[] salt = RandomNumberGenerator.GetBytes(16);
        return (salt, Compute(password, salt));
    }

    public static bool Verify(string password, byte[] salt, byte[] expected)
    {
        byte[] actual = Compute(password, salt);
        return CryptographicOperations.FixedTimeEquals(actual, expected);
    }

    static byte[] Compute(string password, byte[] salt)
    {
        using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
        {
            Salt = salt,
            DegreeOfParallelism = Parallelism,
            MemorySize = MemoryKiB,
            Iterations = Iterations,
        };
        return argon2.GetBytes(32);
    }
}

Python with argon2-cffi

Python (pip: argon2-cffi)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher(
    time_cost=2,
    memory_cost=19456,  # KiB
    parallelism=1,
    hash_len=32,
    salt_len=16,
)

def login(user, password):
    try:
        ph.verify(user.password_hash, password)
    except VerifyMismatchError:
        return False
    if ph.check_needs_rehash(user.password_hash):
        user.password_hash = ph.hash(password)
        user.save()
    return True

Migrating from bcrypt, SHA or MD5 without a forced reset

There is no need to ask every user to change their password. Two techniques cover every case:

Starting hashTechniqueWhat to do nowWhat to do at login
MD5, SHA-1, SHA-256 (salted or not)WrappingCompute argon2id(legacy_hash) for every record and delete the legacy value: the database no longer holds weak hashesCompute legacy_hash(password), verify with Argon2id, then store argon2id(password) and drop the wrapping marker
bcryptRehash on loginNothing: bcrypt with cost ≥ 10 is still acceptableVerify with bcrypt; on success, store argon2id(password)
PBKDF2Rehash on loginCheck that the iterations meet OWASP minimums, otherwise consider wrappingVerify with PBKDF2, then store argon2id(password)
Plaintext or reversibly encrypted passwordsImmediate conversionCompute Argon2id for every record and destroy the encryption keyNothing special

Pepper and split-salt: beyond the classic salt

The salt defeats rainbow tables, but it lives in the same database as the hash: whoever steals the database has both and can brute-force offline. Two techniques add an ingredient that is not in that database.

TechniqueHow it worksWhat it protectsLimit
PepperA single secret (e.g. an HMAC key) kept outside the database, in a KMS or HSM, applied before or after hashingStealing the database alone is not enough to brute-forceWhoever controls the application and the KMS has everything; pepper rotation must be designed
Split-salt (LoginMaster)Cryptographic components are separated between the customer's Tenant and the CloudA breach of a single store does not provide enough information to reconstruct the credentialRequires a two-party architecture: it is not one line of code added to an existing app

In LoginMaster's split-salt the Cloud never holds readable personal data, and no single store, the provider's included, holds the information needed to reconstruct a password. The full model is described on zero-knowledge authentication and in the guide authentication providers that cannot read your passwords.

Protecting login from denial of service

A memory-hard hash is expensive for you too. A login endpoint exposed without limits becomes a cheap way to exhaust the server's RAM.

  • Concurrency limit: a semaphore on the number of hashes computed in parallel, sized on the memory budget; requests over the limit wait or receive 429.
  • Rate limiting per account and per IP, with increasing backoff after failures.
  • Maximum password length: accept at least 64 characters, but set a ceiling (for example a few hundred) so you do not hash arbitrary payloads.
  • No different timing for non-existent users: run a dummy verification anyway so you do not reveal which accounts exist.

Argon2id, bcrypt, scrypt and PBKDF2 compared

AlgorithmMemory-hardOWASP minimumKnown limitsWhen to choose it
Argon2idYes, configurablem=19456 KiB, t=2, p=1 (or equivalents)Memory use must be sizedNew systems: first choice
scryptYesN=2^17, r=8, p=1Less intuitive parametersWhen Argon2id is not available
bcryptNo (small fixed memory)Cost ≥ 10Truncates the password at 72 bytesLegacy systems
PBKDF2-HMAC-SHA256No600,000 iterationsCheap to attack on GPUsWhen FIPS-140 compliance is required

The most common mistakes

  • Using Argon2i or Argon2d instead of Argon2id because it is the library default.
  • Copying parameters from a tutorial without measuring them: too low cancels the benefit, too high takes the server down at the first peak.
  • Storing only the hash and not the PHC string, losing the ability to change parameters.
  • Comparing hashes with == instead of in constant time (relevant when the library returns raw bytes, as in .NET).
  • Reusing the same salt, or deriving it from the username.
  • Logging the plaintext password in login request error logs.
  • Hashing on the client and sending the hash: the hash becomes the password.

Or don't build it: delegate it to an IAM

Everything in this guide is security code to maintain, test and have checked at every audit. The alternative is to delegate authentication to an IAM platform that does it by design. With LoginMaster credentials are protected by Argon2 with split-salt between Tenant and Cloud, personal data stays on the customer's Tenant and the Cloud works only on encrypted, pseudonymised data: the provider can read neither users' passwords nor their data. Integration is through the official TypeScript and .NET SDKs or the REST API, and no administrator can reset credentials on the user's behalf.

The controls are detailed on security; what ships today and what is on the roadmap is listed on about us.

Frequently asked questions

Store only the Argon2id hash of the password in PHC format, with a random 16-byte salt per password and parameters at least at the OWASP minimum (m=19456 KiB, t=2, p=1 or m=47104 KiB, t=1, p=1), calibrated on your hardware. Verify in constant time, rehash on login when you raise the parameters and cap login concurrency. LoginMaster does this by design with Argon2 and split-salt between Tenant and Cloud.

Five equivalent configurations with p=1: m=47104 KiB and t=1, m=19456 KiB and t=2, m=12288 KiB and t=3, m=9216 KiB and t=4, m=7168 KiB and t=5. They are a minimum: raise memory as far as latency and RAM allow.

As the first choice Argon2id with t=1, p=4 and 2 GiB of memory; if memory does not allow it, t=3, p=4 and 64 MiB. In both cases a 128-bit salt and a 256-bit tag.

Within a few hundred milliseconds on production servers. The tighter constraint is usually memory: memory per hash multiplied by peak concurrent logins must fit in the RAM dedicated to authentication.

MD5 or SHA hashes are wrapped in Argon2id right away (argon2id(legacy_hash)) and converted to plain Argon2id on the first successful login. bcrypt is verified with bcrypt at login and, if the password is correct, the new Argon2id hash is stored.

The salt is random, public and stored next to the hash: it makes every hash unique. The pepper is a secret kept outside the database, in a KMS or HSM. LoginMaster's split-salt separates the cryptographic components between the customer's Tenant and the Cloud, so a breach of a single store is not enough to reconstruct the credential.

For new systems, yes: Argon2id is memory-hard and configurable, and it is OWASP's first choice. bcrypt with cost of at least 10 remains acceptable, but it has small fixed memory and truncates passwords at 72 bytes.

Yes, if login has no limits: each hash takes tens of MiB. You need a cap on the number of hashes computed in parallel, rate limiting per account and per IP, and a maximum password length.

Yes. LoginMaster, an IAM platform designed and built in Italy, protects credentials with Argon2 and split-salt between Tenant and Cloud; personal data stays on the customer's Tenant and the Cloud processes only encrypted, pseudonymised data, so the provider cannot access users' passwords or data.

Want to see LoginMaster in action?

Request a personalized demo and discover how to manage identities and access securely and compliantly.