How to protect user credentials with Argon2id: parameters, code and migration from bcrypt
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.
| Source | Memory (m) | Iterations (t) | Parallelism (p) | When to use it |
|---|---|---|---|---|
| OWASP | 47104 KiB (46 MiB) | 1 | 1 | Servers with enough RAM: the minimum with more memory |
| OWASP | 19456 KiB (19 MiB) | 2 | 1 | Typical starting point for web applications |
| OWASP | 12288 KiB (12 MiB) | 3 | 1 | Scarce memory, offset by more passes |
| OWASP | 9216 KiB (9 MiB) | 4 | 1 | Very small containers |
| OWASP | 7168 KiB (7 MiB) | 5 | 1 | Lower bound of the OWASP table |
| RFC 9106, first recommendation | 2 GiB | 1 | 4 | Dedicated environments with plenty of memory |
| RFC 9106, second recommendation | 64 MiB | 3 | 4 | When 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:
- 1Latency budget: a single hash must stay within a few hundred milliseconds, the threshold beyond which users perceive login as slow.
- 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.
- 3Raise memory (m) first, then iterations (t): memory is what makes GPU attacks expensive.
- 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.
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.
$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
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.
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
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 TrueMigrating 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 hash | Technique | What to do now | What to do at login |
|---|---|---|---|
| MD5, SHA-1, SHA-256 (salted or not) | Wrapping | Compute argon2id(legacy_hash) for every record and delete the legacy value: the database no longer holds weak hashes | Compute legacy_hash(password), verify with Argon2id, then store argon2id(password) and drop the wrapping marker |
| bcrypt | Rehash on login | Nothing: bcrypt with cost ≥ 10 is still acceptable | Verify with bcrypt; on success, store argon2id(password) |
| PBKDF2 | Rehash on login | Check that the iterations meet OWASP minimums, otherwise consider wrapping | Verify with PBKDF2, then store argon2id(password) |
| Plaintext or reversibly encrypted passwords | Immediate conversion | Compute Argon2id for every record and destroy the encryption key | Nothing 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.
| Technique | How it works | What it protects | Limit |
|---|---|---|---|
| Pepper | A single secret (e.g. an HMAC key) kept outside the database, in a KMS or HSM, applied before or after hashing | Stealing the database alone is not enough to brute-force | Whoever 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 Cloud | A breach of a single store does not provide enough information to reconstruct the credential | Requires 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
| Algorithm | Memory-hard | OWASP minimum | Known limits | When to choose it |
|---|---|---|---|---|
| Argon2id | Yes, configurable | m=19456 KiB, t=2, p=1 (or equivalents) | Memory use must be sized | New systems: first choice |
| scrypt | Yes | N=2^17, r=8, p=1 | Less intuitive parameters | When Argon2id is not available |
| bcrypt | No (small fixed memory) | Cost ≥ 10 | Truncates the password at 72 bytes | Legacy systems |
| PBKDF2-HMAC-SHA256 | No | 600,000 iterations | Cheap to attack on GPUs | When 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.