How to Hash Passwords with Argon2
Hash passwords with Argon2id so GPU cracking is uneconomical, and tune the memory and time cost to your hardware budget.
On this page
Introduction
The hardest part of store passwords safely is not writing the code — it is deciding what the code is allowed to reject. A Argon2 setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in Argon2, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.
Argon2 earns its place when store passwords safely is the bottleneck the user feels. Before that, it is overhead that slows the common case to protect a rare one. Measure the rejection rate and the tail latency, then decide whether the guard is worth its cost. A guard that never fires is a guard you can remove; a guard that fires constantly is a signal that the producer contract is wrong, not that the guard is too strict.
The sections below walk through the concrete decisions: where to put the argon2 check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.
Choose Argon2id
Choose Argon2id is the first decision because it determines what the rest of the Argon2 pipeline can assume. A wrong choice here turns into store passwords safely under load, and the invariant you thought Argon2 guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Choose Argon2id is the one most teams skip: drive argon2 to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
import argon2 from 'argon2';
const hash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 65536, timeCost: 3 });
const ok = await argon2.verify(hash, userInput);
The javascript above is the shape to ship for store passwords safely. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.
Set memory and time cost
The mechanism behind Set memory and time cost is straightforward in Argon2, but the consequences ripple. A wrong choice here turns into store passwords safely under load, and the invariant you thought Argon2 guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Set memory and time cost is the one most teams skip: drive argon2 to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Generate a salt
Generate a salt is where most Argon2 implementations leak performance. A wrong choice here turns into store passwords safely under load, and the invariant you thought Argon2 guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Generate a salt is the one most teams skip: drive argon2 to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
test('verify accepts correct password', async () => {
expect(await argon2.verify(hash, 'correct')).toBe(true);
});
Pin the behavior with this test. A Argon2 setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.
Hash and store
The hard part of Hash and store is not the happy path — it is what happens when argon2 fails halfway. A wrong choice here turns into store passwords safely under load, and the invariant you thought Argon2 guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Hash and store is the one most teams skip: drive argon2 to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Verify on login
Verify on login should be observable without a debugger. A wrong choice here turns into store passwords safely under load, and the invariant you thought Argon2 guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Verify on login is the one most teams skip: drive argon2 to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Test hashing
Test hashing is a trade, not a winner. A wrong choice here turns into store passwords safely under load, and the invariant you thought Argon2 guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Test hashing is the one most teams skip: drive argon2 to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Common pitfalls
The common pitfalls in store passwords safely all reduce to one mistake: treating Argon2 as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.
- Skipping the argon2 boundary so store passwords safely enters unvalidated.
- Assuming Argon2 is fast without measuring the tail latency under load.
- Letting store passwords safely fail silently instead of failing loudly with a metric.
- Testing only the happy path so the argon2 failure surfaces in production.
- Sharing mutable state across requests so one bad input poisons the next.
Conclusion
Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: Argon2 fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.
Self-check: write a test that drives argon2 to the failing condition and assert the system degrades gracefully. Then store passwords safely is a property you maintain, not a feature you hope for.
Sources: Argon2, OWASP Password Storage
FAQ
- Why Argon2id?
- It resists both GPU and side-channel attacks. bcrypt and scrypt are older alternatives.
- What memory cost?
- 64MB is a good start. Tune so the hash takes ~250ms on your hardware.
Report a problem with this articleOpens a pre-filled issue on GitHub.