Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: A fast hash lets a stolen table be guessed millions of times a second · Argon2id, scrypt, bcrypt and PBKDF2, and one record that stores its settings. With a pass: 3 in-depth parts and 3 interview answers, about 13 minutes.

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:

Guesses a second against a SHA-256 record and an Argon2id record JavaScript · guess-js/guess_rate.mjs
// 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

  1. Question 1 of 2 A server stores unsalted SHA-256 password hashes, and one core tries 2,000,000 guesses a second. How many seconds does a list of 10,000,000 likely passwords take against the whole table?

    Type a number.

    A hint

    Without salts, one hash of each guess can be compared with every record at once.

    Show the answer: Question 1 of 2

    Answer: 5 seconds

    10,000,000 ÷ 2,000,000 = 5 seconds, and because the hashes are unsalted, the same 5 seconds cover every record in the table: each guess is hashed once and looked up among all the records.

  2. Question 2 of 2 What does a random salt per record change for someone who has stolen the records?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: They must hash their list of guesses again for every record, and equal passwords no longer show as equal records

    A salt makes every record a separate target: there is no single hash per guess that matches many records, and no precomputed table of hashes that fits them all. The cost of each guess comes from the function's settings.

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:

RFC 9106's Argon2id vector in node:crypto and Web Crypto JavaScript · rfc9106-js/argon2_rfc9106.mjs
// 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:

Interactive · stage by stage One password record, from sign-up to login Step through what the server stores when someone sets a password, and what it reads back when they sign in.
One password record, from sign-up to login, step 6 of 6. Showing A new random salt, Argon2id of the password, One stored record, At login: read it back, Hash the typed password, Compare in constant time. In focus: Compare in constant time.not equal: refuseA new random salt16 bytes from a CSPRNGArgon2id of the passwordm=19456 KiB, t=2, p=1One stored record$argon2id$v=19$m=19456,…At login: read it backsettings, salt and hashHash the typed passwordwith the record's settingsCompare in constant timeequal: signed insame work for every answer

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 Your service must use FIPS-140 compliant cryptography for stored passwords. Which function does OWASP's cheat sheet point to?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: PBKDF2, with at least 600,000 iterations of HMAC-SHA-256

    OWASP's order is Argon2id, then scrypt, bcrypt only for legacy systems, and PBKDF2 where FIPS-140 compliance is required, at 600,000 iterations or more with HMAC-SHA-256.

  2. Question 2 of 2 Which of these does a PHC record of an Argon2id password hash hold?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • The function's name and version
    • The salt and the hash
    • The settings, such as memory, passes and lanes

    One record carries everything needed to check a password later: the function, its version, its settings, the salt and the hash. At login the server reads the settings from the record, not from its current policy.

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.

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.

  1. Question 1 of 7 How does a thief who has stolen hashed passwords usually find the passwords?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: By hashing likely passwords and comparing the results with the records

    Stolen password hashes are attacked by guessing. The cost of each guess, set by the function and its settings, is what protects a record.

  2. Question 2 of 7 One core makes 2,000,000 SHA-256 guesses a second or 80 Argon2id guesses a second. How many hours does a list of 10,000,000 guesses take against one Argon2id record, rounded to the nearest hour?

    Type a number.

    Show the answer: Question 2 of 7

    Answer: 35 hours (anything from 34.5 to 35.5 counts)

    10,000,000 ÷ 80 = 125,000 seconds, and 125,000 ÷ 3,600 ≈ 34.7, about 35 hours, for one record. The same list against a SHA-256 record takes 5 seconds.

  3. Question 3 of 7 An attacker builds hardware that runs a thousand guesses side by side. Why does a memory-hard function cost them more than PBKDF2 at the same time per guess?

    Choose one answer.

    Show the answer: Question 3 of 7

    Answer: Each of the thousand guesses needs its own block of memory, so the hardware must carry a thousand times the memory

    Arithmetic is cheap to replicate on special hardware; memory is not. A function that needs a large block of memory per guess keeps parallel guessing expensive.

  4. Question 4 of 7 Which function does OWASP's Password Storage Cheat Sheet put first for new systems?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: Argon2id

    Argon2id first, scrypt where it is missing, bcrypt only in legacy systems, and PBKDF2 for FIPS-140 compliance.

  5. Question 5 of 7 What sets scrypt's memory and its time?

    Choose one answer.

    Show the answer: Question 5 of 7

    Answer: Mostly one cost, N, which raises both together

    scrypt has one main cost, N, and raising it raises the memory and the time together. Argon2id sets memory and passes apart.

  6. Question 6 of 7 A new library offers Argon2id. What do you check before trusting it?

    Choose one answer.

    Show the answer: Question 6 of 7

    Answer: That it gives RFC 9106's published tag for the RFC's test inputs

    Run the published vector before anything else: RFC 9106 section 5.3 gives inputs and a tag for Argon2id, and any correct implementation must reproduce them.

  7. Question 7 of 7 A product team wants to allow passphrases of up to 100 characters, in any script. Why is bcrypt a poor fit?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: bcrypt reads at most 72 bytes of input, and passphrases in many scripts pass 72 bytes long before 100 characters

    bcrypt's 72-byte input limit, which OWASP's cheat sheet states, rules it out where long passphrases must work in full; the lesson's table names no such limit for the other three functions.

Interview questions

  1. 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.

  2. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.