Password hashing: no two of these frameworks agree
The advice on this subject is unusually uniform. Use an adaptive hash; prefer Argon2id; bcrypt is fine. What actually ships is not uniform at all — four different algorithms across five frameworks, and no two of them agree.
That matters less than it sounds, and more than it looks. Every default below is a genuine adaptive hash and none of them is a finding. The risk is not that your framework picked badly. It is that you assumed it picked what the advice said, and it did not.
Measured
Section titled “Measured”The same password hashed with each framework’s own default, on the versions listed above.
| Framework | What ships | Default algorithm | Observed parameters |
|---|---|---|---|
| Django 6.1 | hashing, in core | PBKDF2-SHA256 | 1500000 iterations |
| Django 5.2 LTS | hashing, in core | PBKDF2-SHA256 | 1000000 iterations |
| Flask / Werkzeug | hashing, in werkzeug.security |
scrypt | 32768:8:1 |
| FastAPI | nothing — its tutorial installs pwdlib |
Argon2id | m=65536, t=3, p=4 |
| Express | nothing — but Node core has three | — | see below |
| Spring Security | hashing, in core | bcrypt | strength 10 |
Four algorithms: PBKDF2, scrypt, Argon2id, bcrypt. Nobody agrees with anybody.
The three that surprise people
Section titled “The three that surprise people”Flask ships password hashing, and its default is scrypt. This is the one most often
got wrong, including in notes I had written before measuring. werkzeug.security exports
generate_password_hash(password, method="scrypt", salt_length=16), and it has been
producing scrypt:32768:8:1$… hashes without anyone installing anything. “Flask ships
nothing for passwords” is false — that is FastAPI.
Django’s default is PBKDF2, not Argon2, and Argon2 is right there in the list.
PASSWORD_HASHERS ships with five entries and Argon2 is the third of them; the first is
PBKDF2. Django’s own documentation explains the reason, and it is dependency management
rather than cryptography — Argon2 needs a third-party library, so it cannot be the
default of a batteries-included framework. Note also that the iteration count is not a
constant: 1,000,000 on the 5.2 LTS and 1,500,000 on 6.1, because Django raises it
every release. A hash written by an older Django is upgraded transparently the next time
that user logs in, which is why the list is ordered rather than a single value.
Node now ships Argon2, so “npm install argon2” is newly out of date. Express itself
has nothing, as expected — but the runtime under it does. crypto.argon2() and
crypto.argon2Sync() were added in Node 24.7.0 (2025-08-27) and marked stable in
24.19.0 (2026-08-03), alongside the crypto.scrypt and crypto.pbkdf2 that have been
there for years. Reproduced on Node 25.9:
const { argon2Sync } = require("node:crypto");
const tag = argon2Sync("argon2id", { message: Buffer.from(password), nonce: crypto.randomBytes(16), // the salt passes: 3, parallelism: 4, memory: 65536, tagLength: 32,});This is a raw primitive, not a password-storage library: it returns a tag, and you are
responsible for storing the salt and parameters alongside it and for comparing in
constant time. A package that produces a self-describing $argon2id$v=19$… string is
still doing real work. But the reflex answer — that Argon2 in Node requires a native
dependency — stopped being true, and anything written before late 2025 says otherwise.
The two that are stable
Section titled “The two that are stable”Spring Security uses DelegatingPasswordEncoder with an id of bcrypt, at
BCryptPasswordEncoder’s default strength of 10. Its documentation is candid that this
is not the strongest option available — it states plainly that “Argon2 is the winner of
the Password Hashing Competition” — and keeps bcrypt as the default anyway. The
delegating design is the interesting part: stored hashes carry a {bcrypt} prefix, so
the encoder can read several formats and the default can move without a migration.
FastAPI ships nothing, which is consistent with the rest of that framework, and its
tutorial has changed its answer. It previously installed passlib; it now installs
pwdlib[argon2] and calls PasswordHash.recommended(), which resolves to Argon2id.
Worth knowing if you scaffolded from the older tutorial: passlib’s last release is
1.7.4, published 2020-10-08. As with the JWT library, the documentation changed and
nothing told existing applications.
What to do about it
Section titled “What to do about it”Mostly nothing, and that is the honest answer. PBKDF2 at 1.5 million iterations, scrypt at 32768:8:1, bcrypt at strength 10 and Argon2id at 64 MB are all defensible in 2026. Rewriting a working password store to change which defensible algorithm it uses is rarely the best available security work.
What is worth doing:
- Find out which one you actually have, by reading a hash out of your own database
rather than reading your framework’s documentation. Every format above is
self-identifying:
pbkdf2_sha256$,scrypt:,$argon2id$,{bcrypt}$2a$. - Check the work factor, not the algorithm name. An algorithm is not a security property on its own; bcrypt at strength 4 is worse than PBKDF2 at 1.5 million.
- Confirm the upgrade path exists. Django’s ordered hasher list and Spring’s
{id}prefix both re-hash on successful login. If you rolled your own, you probably have no such path, and that is the finding worth having.
Related
Section titled “Related”- Sessions: signed, not encrypted — the other place these frameworks disagree about a default
- Django SECRET_KEY — the other credential-shaped Django setting with a version-dependent default
- Express session secret and store — what happens after the password check succeeds