Applied Cryptography for Developers Module 6 – Key derivation and password storage
Password hashing: Argon2id, scrypt and bcrypt
Why fast hashes fail for passwords, what Argon2id, scrypt, bcrypt and PBKDF2 add, which one guidance picks, and Argon2id checked against RFC 9106.
What you will learn
- Explain why fast hashes are unsafe for passwords and what memory-hard functions add
- Choose Argon2id, scrypt, bcrypt or PBKDF2 and its settings from current guidance
- Verify Argon2id and scrypt implementations against the vectors of RFC 9106 and RFC 7914
- Store algorithm, parameters, salt and hash together in a PHC-style string and read it back strictly
Before you start
What you’ll learn · Before you start
What you will learn
- Explain why fast hashes are unsafe for passwords and what memory-hard functions add
- Choose Argon2id, scrypt, bcrypt or PBKDF2 and its settings from current guidance
- Verify Argon2id and scrypt implementations against the vectors of RFC 9106 and RFC 7914
- Store algorithm, parameters, salt and hash together in a PHC-style string and read it back strictly
Before you start
On this page
A table of password records leaks. If each record is a SHA-256 of the password, one core in this lesson’s recorded run tried about a million guesses a second, so a list of ten million likely passwords took under ten seconds; with Argon2id at OWASP’s setting the same core managed about 37 a second, and the list took 75 hours for every single record.
That gap is what a password hash is for. This lesson shows why fast hashes fail, compares the four functions current guidance names, Argon2id, scrypt, bcrypt and PBKDF2, runs Argon2id against RFC 9106’s test vector, and follows one stored record from sign-up to the login that checks it.
A fast hash lets a stolen table be guessed millions of times a second
People choose passwords from a small set of likely words and patterns, so a thief with the records does not break the hash; they hash likely passwords until one matches. What protects a record then is the cost of each guess. The program measures it on one core, for a record that is a SHA-256 and for one that is Argon2id with 19 MiB of memory:
// How fast one core of this computer tries password guesses against a stolen record, when the record is a SHA-256 of
// the password and when it is Argon2id with OWASP's setting m=19456 KiB, t=2, p=1. The times change from run to run.
import { argon2Sync, createHash } from 'node:crypto';
const salt = Buffer.from('a-16-byte-salt!!'); // a lab salt; real salts come from a CSPRNG
const perSecond = (work, calls) => {
const start = performance.now();
for (let i = 0; i < calls; i++) work(`guess-${i}`);
return calls / ((performance.now() - start) / 1000);
};
const sha = perSecond((guess) => createHash('sha256').update(guess).digest(), 200_000);
const argon = perSecond(
(guess) => argon2Sync('argon2id', { message: guess, nonce: salt, memory: 19456, passes: 2, parallelism: 1, tagLength: 32 }),
20,
);
const list = 10_000_000; // a list of likely passwords, tried against one record
const hours = (rate) => list / rate / 3600;
console.log(`SHA-256: ${Math.round(sha).toLocaleString('en')} guesses a second`);
console.log(`Argon2id m=19 MiB, t=2, p=1: ${Math.round(argon).toLocaleString('en')} guesses a second`);
console.log(`10,000,000 guesses: ${(list / sha).toFixed(1)} s with SHA-256, ${hours(argon).toFixed(0)} hours with Argon2id`);
console.log(`Argon2id costs about ${Math.round(sha / argon).toLocaleString('en')} times more per guess, and 19 MiB of memory`); Output
SHA-256: 1,021,307 guesses a second Argon2id m=19 MiB, t=2, p=1: 37 guesses a second 10,000,000 guesses: 9.8 s with SHA-256, 75 hours with Argon2id Argon2id costs about 27,662 times more per guess, and 19 MiB of memory
This output changes from run to run: the times depend on the computer and change from run to run
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node guess_rate.mjs
In the recorded run each Argon2id guess cost about 28,000 times as much as a SHA-256 guess. A password hashing function: A slow, salted function for storing passwords, such as Argon2id, scrypt, bcrypt or PBKDF2, tuned so that every guess at a stolen record costs an attacker real time and, for the memory-hard ones, memory. is built to be slow on purpose, with a cost factor: A setting that makes a password hash slower on purpose, such as bcrypt's cost, PBKDF2's iterations or Argon2id's memory and passes. It is raised as computers get faster, as far as the server can bear. you raise as computers get faster. Two more properties finish the job. A salt: A non-secret value mixed into a key derivation or a password hash. HKDF may reuse one fixed salt, while every stored password record needs its own random salt, so that equal passwords give different records., a random value stored with each record, gives two people with the same password different records, so an attacker must guess for every record separately instead of hashing the list once for the whole table. And a memory-hard function: A function that needs a large block of memory for every evaluation, such as Argon2id or scrypt, so that hardware running many guesses side by side pays for memory in each of them as well as for computing. needs a block of memory for every guess, so hardware built to run many guesses side by side pays for memory in each of them, not only for arithmetic.
Checkpoint
Check: Why fast hashes fail for passwords
Argon2id, scrypt, bcrypt and PBKDF2, and one record that stores its settings
Current guidance names four functions, and OWASP’s Password Storage Cheat Sheet ranks them. Argon2id comes first. When it is missing, scrypt takes its place; bcrypt belongs to legacy systems that have neither; and PBKDF2 is the choice whenever FIPS-140 compliance is a requirement:
| Function | What you set | Memory-hard | OWASP’s use and a listed setting |
|---|---|---|---|
| Argon2id, RFC 9106 | memory, passes, lanes | yes | the first choice: 19 MiB, 2 passes, 1 lane |
| scrypt, RFC 7914 | cost N, block size, lanes | yes | when Argon2id is missing: N = 2¹⁷, r = 8, p = 1 |
| bcrypt, no RFC or NIST standard | a cost, each step doubling the work | no | legacy systems only: cost 10 or more, at most 72 bytes |
| PBKDF2, RFC 8018 and NIST SP 800-132 | iterations of HMAC | no | FIPS-140 compliance: 600,000 iterations of HMAC-SHA-256 |
Argon2id is the variant RFC 9106 tells you to choose when you are unsure, and its memory and passes are set apart.
scrypt is memory-hard as well, with one cost, N, that sets its memory and its time together. bcrypt is slow but
not memory-hard, and it reads at most 72 bytes of a password. PBKDF2 repeats HMAC as often as you ask, so it
is slow but cheap to run on many guesses at once. A function you use must give its published values, so the program
runs the Argon2id test vector of RFC 9106 through Node’s built-in argon2Sync and through Web Crypto:
// The Argon2id test vector of RFC 9106 section 5.3 through node:crypto's argon2Sync (Node.js 24.7.0 and later) and
// through Web Crypto's Argon2id, which Node.js 24 offers as an experimental algorithm and announces with a warning.
import { argon2Sync } from 'node:crypto';
process.removeAllListeners('warning'); // print Node's warning as one plain line instead of its default form
process.on('warning', (w) => console.log(`(Node.js says: ${w.name}: ${w.message})`));
const RFC_TAG = '0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659';
const password = Buffer.alloc(32, 0x01); // the vector's inputs: 32, 16, 8 and 12 bytes
const salt = Buffer.alloc(16, 0x02);
const secret = Buffer.alloc(8, 0x03);
const associated = Buffer.alloc(12, 0x04);
const tag = argon2Sync('argon2id', {
message: password,
nonce: salt,
secret,
associatedData: associated,
memory: 32, // KiB: the smallest Argon2 allows for 4 lanes, 8 per lane
passes: 3,
parallelism: 4,
tagLength: 32,
}).toString('hex');
console.log(`argon2Sync: ${tag}`);
console.log(` as RFC 9106 section 5.3: ${tag === RFC_TAG}`);
const key = await crypto.subtle.importKey('raw-secret', password, 'Argon2id', false, ['deriveBits']);
const params = { name: 'Argon2id', nonce: salt, secretValue: secret, associatedData: associated, memory: 32, passes: 3, parallelism: 4 };
const web = Buffer.from(await crypto.subtle.deriveBits(params, key, 256)).toString('hex');
console.log(`Web Crypto: as RFC 9106 section 5.3: ${web === RFC_TAG}`); Output
argon2Sync: 0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659 as RFC 9106 section 5.3: true (Node.js says: ExperimentalWarning: The Argon2id Web Crypto API algorithm is an experimental feature and might change at any time) Web Crypto: as RFC 9106 section 5.3: true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node argon2_rfc9106.mjs
Both match the RFC’s tag. Whatever the function, the stored record carries everything needed to check a password later, in the PHC string format: A text format for password records, $id$v=version$settings$salt$hash, as in $argon2id$v=19$m=19456,t=2,p=1$…, so that one record carries everything needed to check a password later.: the function, its version, the settings, the salt and the hash. Step through one record from sign-up to login:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A salt per record. At sign-up the server draws a fresh salt of 16 random bytes for this one record, so two people with the same password get different records.
- The slow hash. It runs Argon2id over the password and the salt with the settings of its policy, here 19 MiB of memory, 2 passes and 1 lane, and gets a 32-byte hash.
- One record holds it all. It stores the function, its version, the settings, the salt and the hash as one PHC string, 97 characters here. The password itself is never stored.
- Read the record back. At login the server finds the record by the account name and reads the settings and the salt from the record itself, not from today's policy.
- Hash the attempt. It runs Argon2id over the typed password with exactly those settings and that salt. A record made under older settings still checks correctly.
- Compare. It compares the new hash with the stored one in constant time. Equal means the password was right; anything else is refused with the same message.
Because the record carries its own settings, a login years later still checks it correctly, whatever the policy has moved to by then.
Checkpoint
Check: Argon2id, scrypt, bcrypt and PBKDF2
What memory and passes cost, measured, with RFC 7914's vectors Pro: opens with a Pro, Premium or Ultimate pass
Each function spends memory and time in its own way, and the vectors of its RFC prove that an implementation computes it exactly. This part measures scrypt and Argon2id as their settings grow and runs every RFC 7914 vector that fits a laptop’s memory.
In this part:
- Memory-hard in numbers: scrypt as N doubles
- Argon2id’s memory and passes trade against each other
3 runnable examples (JavaScript, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Choosing settings from guidance, with worked numbers Pro: opens with a Pro, Premium or Ultimate pass
Guidance gives floors, and your servers decide how far above them you can go. This part sets each function from its guidance and works out what a setting costs a login server at its busiest.
In this part:
- Argon2id: OWASP’s floors and RFC 9106’s two recommended settings
- bcrypt: a logarithmic cost and a 72-byte input
- PBKDF2 when FIPS 140 is required, and what NIST asks of every scheme
1 runnable example (Python) · about 6 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Records that carry their settings, read strictly, with a lab Pro: opens with a Pro, Premium or Ultimate pass
A stored record is untrusted input the next time it is read: a restore, a migration or someone with write access to the table can change it. This part writes and reads PHC records, refuses the ones that bend the format, and bounds what a record may ask a server to spend.
In this part:
- A PHC record, field by field
- Reading a record strictly, and bounding what it may cost
- Parse, write and check PHC password records
- Results of the sample tests
2 runnable examples (JavaScript, Python) · 1 exercise with sample tests · about 6 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
Test the two ideas of the opening sections. Questions come weakest idea first, get harder as you answer well, and return when you slip:
Practice that adapts to you
Practise this lesson
7 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
Why is SHA-256 with a salt still a poor way to store passwords?
Because a salt only stops an attacker from hashing a list once for the whole table; it does nothing to the cost of each guess, and SHA-256 is built to be fast. In this lesson's run one core tried about a million SHA-256 guesses a second, so a list of ten million likely passwords takes seconds per record. A password hash is slow on purpose: Argon2id at OWASP's setting allowed about 37 guesses a second on the same core and needs 19 MiB of memory for each, which also makes parallel hardware expensive. So use Argon2id, or scrypt, bcrypt for legacy stores and PBKDF2 where FIPS-140 compliance is required, with a salt per record, and store the settings, the salt and the hash together in a PHC string.
-
Mid to senior
Your login service runs on instances with 2 vCPUs and 4 GiB of memory, and peaks at 40 logins a second. Choose Argon2id settings and justify them with numbers.
-
Mid to senior
Your app stores bcrypt hashes. A user reports that their long passphrase still lets them in after they mistyped its last word. Explain what happened, and fix it without breaking existing logins.
-
Senior to staff
A customer requires FIPS 140 compliant cryptography for your password store, which uses Argon2id today. What do you change, with which settings, and what do you give up?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why does a fast hash fail for stored passwords, in numbers?
People choose passwords from a small set of likely words, so a thief with the table guesses rather than breaks the hash. In the recorded run one core tried about a million SHA-256 guesses a second, so ten million likely passwords took under ten seconds; with Argon2id at 19 MiB the same core managed about 37 a second, 75 hours for every record.
-
What do slowness, a salt and memory-hardness each add to a password hash?
A cost factor makes each guess slow, and you raise it as computers get faster. A random salt stored with each record gives equal passwords different records, so every record is a separate target. A memory-hard function needs a block of memory for every guess, so hardware that runs many guesses side by side pays for memory in each, not only for arithmetic.
-
How does OWASP rank Argon2id, scrypt, bcrypt and PBKDF2?
Argon2id first. scrypt when Argon2id is missing. bcrypt only for legacy systems that have neither. PBKDF2 whenever FIPS-140 compliance is required.
-
Which starting settings does OWASP list for each password function?
Argon2id (RFC 9106): 19 MiB of memory, 2 passes, 1 lane. scrypt (RFC 7914): N = 2^17, r = 8, p = 1. bcrypt: cost 10 or more, and it ignores everything after the first 72 bytes of a password. PBKDF2 (RFC 8018, SP 800-132): 600,000 iterations of HMAC-SHA-256.
-
How do Argon2id, scrypt, bcrypt and PBKDF2 differ in design?
Argon2id sets memory, passes and lanes apart and is the variant RFC 9106 says to choose when unsure. scrypt is memory-hard with one cost, N, that sets memory and time together. bcrypt is slow but not memory-hard. PBKDF2 repeats HMAC as often as you ask: slow, but cheap to run on many guesses at once.
-
What does a stored password record in PHC string format hold, and why?
The function, its version, the settings, the salt and the hash. Because the record carries its own settings, a login years later still checks it correctly, whatever the policy has moved to by then.
Key takeaways
- A thief guesses passwords rather than breaking the hash, so the cost of each guess protects a record: SHA-256 allowed about 28,000 guesses for every Argon2id guess in the recorded run.
- A salt per record makes every record a separate target, and a memory-hard function makes every guess pay for memory as well as computing.
- Choose Argon2id first, scrypt where Argon2id is missing, PBKDF2 where FIPS-140 compliance is required, and bcrypt only for legacy stores, and check any implementation against its RFC’s vectors, as RFC 9106’s Argon2id vector was.
- Store the function, its settings, the salt and the hash together in one PHC string, so every record can be checked with its own settings years later.
Terms in this lesson
- cost factor
- A setting that makes a password hash slower on purpose, such as bcrypt's cost, PBKDF2's iterations or Argon2id's memory and passes. It is raised as computers get faster, as far as the server can bear.
- memory-hard function
- A function that needs a large block of memory for every evaluation, such as Argon2id or scrypt, so that hardware running many guesses side by side pays for memory in each of them as well as for computing.
- password hashing function
- A slow, salted function for storing passwords, such as Argon2id, scrypt, bcrypt or PBKDF2, tuned so that every guess at a stolen record costs an attacker real time and, for the memory-hard ones, memory.
- PHC string format
- A text format for password records, $id$v=version$settings$salt$hash, as in $argon2id$v=19$m=19456,t=2,p=1$…, so that one record carries everything needed to check a password later.
- salt
- A non-secret value mixed into a key derivation or a password hash. HKDF may reuse one fixed salt, while every stored password record needs its own random salt, so that equal passwords give different records.
References
- RFC 9106: Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications (Internet Engineering Task Force (IETF))
- RFC 7914: The scrypt Password-Based Key Derivation Function (Internet Engineering Task Force (IETF))
- RFC 8018: PKCS #5, Password-Based Cryptography Specification Version 2.1 (PBKDF2) (Internet Engineering Task Force (IETF))
- OWASP Password Storage Cheat Sheet (OWASP Foundation)
- SP 800-132: Recommendation for Password-Based Key Derivation, Part 1: Storage Applications (National Institute of Standards and Technology (NIST))
- SP 800-63B-4: Authentication and Authenticator Management (section 3.1.1.2) (National Institute of Standards and Technology (NIST))
- PHC string format (C2SP (Community Cryptography Specification Project))
- bcrypt 5.0.0 on PyPI (changelog: passwords over 72 bytes) (The Python Cryptographic Authority (PyCA))
- Node.js 24 documentation: Crypto (crypto.argon2, argon2Sync and scrypt) (OpenJS Foundation (Node.js))
- Python 3.14 documentation: hashlib (scrypt and pbkdf2_hmac) (Python Software Foundation)
- OWASP Application Security Verification Standard 5.0, V11 Cryptography (requirement 11.4.2) (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress