July 11, 2026
Why We Hash Passwords Instead of Encrypting Them: A Node.js Guide to bcrypt, Argon2id, and PCI DSS
A breakdown of hashing versus encryption for developers who store customer passwords, with working TypeScript examples using bcrypt…

By FOLAKE SOWONOYE
10 min read
A breakdown of hashing versus encryption for developers who store customer passwords, with working TypeScript examples using bcrypt, Argon2id, and Node's built-in crypto module, mapped to OWASP and PCI DSS requirements.
If you are a Node.js or TypeScript developer building a sign-up form for a product that will hold real customer accounts, this is for you. Every login system needs to check a password somewhere, and the decision you make about how to store it decides whether a database breach costs your company a password reset email or a regulatory investigation. This article explains, in plain terms and with working code, why passwords are hashed instead of encrypted, why hashing by itself is still not enough, and what OWASP (Open Web Application Security Project) and PCI DSS (Payment Card Industry Data Security Standard) actually require you to do about it.
Encryption and Hashing Solve Different Problems
Encryption is a two-way operation. You encrypt data with a key, and anyone who holds that same key can decrypt it and get the original value back. This is exactly right for data your system needs to read again later, a customer's shipping address, an API response, a stored card token.
Hashing is a one-way operation. A hashing function takes an input and produces a fixed-length output, but there is no key and no reverse function. You cannot "decrypt" a hash back into the original password. The only way to check a password is to hash whatever the user just typed and compare the result to the hash you stored.
That difference is exactly why passwords are hashed and not encrypted. A login form never needs the original password back. It only ever needs to answer one question: does this new input produce the same result as before? Hashing answers that question without ever storing anything an attacker could reverse into a working password. Encryption, by contrast, keeps the original password recoverable by design, which means a stolen key hands an attacker every customer's real password, all at once. I go through the correct use of encryption for data you do need back, including per-request salts and key storage, in a separate piece on encrypting API responses end-to-end.
What Goes Wrong When You Encrypt Passwords Instead: The Adobe Breach
In October 2013, Adobe was breached, and researchers later confirmed that the exposed password database had not been hashed, it had been encrypted with 3DES (Data Encryption Standard) in ECB mode (Electronic Codebook), using the same key for every single user. Adobe initially reported around 38 million active users affected; once the leaked database itself was analyzed, it turned out to contain encrypted password entries for more than 130 million accounts.
ECB mode has a well known flaw: identical plaintext blocks always produce identical ciphertext blocks. Because Adobe reused one key for everyone, any two users who happened to choose the same password ended up with byte-for-byte identical encrypted output. The leaked database also included plaintext password hints, and a large number of users had, unhelpfully, typed their actual password as its own hint. Security researcher Jeremi Gosney combined those two facts, matching identical ciphertext blocks to their shared plaintext hints, and produced Adobe's top 100 most common passwords within about three hours. Nobody ever had to crack the encryption key to do this.
That is the structural problem with encrypting passwords. It is not just that Adobe made a configuration mistake with an outdated cipher mode. It is that encryption keeps a path back to the original value, and every property of that path, block patterns, shared keys, a leaked key, becomes something an attacker can exploit. Hashing removes that path entirely there is no key to protect, because there is nothing to decrypt.
Here is what that mistake looks like in code, so you can recognize it if you ever see it in a codebase:
import {
randomBytes,
createCipheriv,
createDecipheriv,
CipherGCM,
DecipherGCM,
} from "crypto";
// ANTI-PATTERN - do not store passwords this way.
// Shown only so the pattern is recognizable. Encryption is reversible
// by design, which is exactly wrong for a password.
function encryptPassword(password: string, key: Buffer): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv) as CipherGCM;
const encrypted = Buffer.concat([
cipher.update(password, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]).toString("base64");
}
// Anyone holding `key` can run this against the whole database
// and read every customer's password in plaintext, at once.
function decryptPassword(payload: string, key: Buffer): string {
const data = Buffer.from(payload, "base64");
const iv = data.subarray(0, 12);
const authTag = data.subarray(12, 28);
const encrypted = data.subarray(28);
const decipher = createDecipheriv("aes-256-gcm", key, iv) as DecipherGCM;
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(
"utf8"
);
}import {
randomBytes,
createCipheriv,
createDecipheriv,
CipherGCM,
DecipherGCM,
} from "crypto";
// ANTI-PATTERN - do not store passwords this way.
// Shown only so the pattern is recognizable. Encryption is reversible
// by design, which is exactly wrong for a password.
function encryptPassword(password: string, key: Buffer): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv) as CipherGCM;
const encrypted = Buffer.concat([
cipher.update(password, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]).toString("base64");
}
// Anyone holding `key` can run this against the whole database
// and read every customer's password in plaintext, at once.
function decryptPassword(payload: string, key: Buffer): string {
const data = Buffer.from(payload, "base64");
const iv = data.subarray(0, 12);
const authTag = data.subarray(12, 28);
const encrypted = data.subarray(28);
const decipher = createDecipheriv("aes-256-gcm", key, iv) as DecipherGCM;
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(
"utf8"
);
}The decryptPassword function is the whole problem. Its existence means a single leaked key turns every stored password back into plaintext, instantly, for every user at once.
Why Hashing Alone Is Not Enough
Switching to a hash function does not automatically make password storage safe. Ordinary cryptographic hash functions like SHA-256, SHA-1, or MD5 (Message-Digest algorithm 5) are built to be fast, which is exactly the wrong property for a password check. A modern GPU can test tens of billions of SHA-1 or MD5 guesses per second. Given a stolen database of fast, unsalted hashes, an attacker does not need years, they need minutes to work through common password lists and every short password.
LinkedIn learned this in 2012. Attackers stole roughly 6.5 million password hashes, later found to be part of a set covering around 117 million accounts, and the hashes were plain, unsalted SHA-1. Because SHA-1 is fast to compute and there was no salt to slow down matching against precomputed tables, roughly 90 percent of the exposed passwords were cracked within 72 hours.
import { createHash } from "crypto";
// ANTI-PATTERN - fast, unsalted hash.
// Crackable at billions of guesses per second on commodity GPU hardware.
function weakHash(password: string): string {
return createHash("sha256").update(password).digest("hex");
}import { createHash } from "crypto";
// ANTI-PATTERN - fast, unsalted hash.
// Crackable at billions of guesses per second on commodity GPU hardware.
function weakHash(password: string): string {
return createHash("sha256").update(password).digest("hex");
}This is why OWASP's Password Storage Cheat Sheet rules out SHA-256, SHA-1, and MD5 for password storage entirely. What it asks for instead is a hashing function that is deliberately slow and, ideally, memory-hard Argon2id, bcrypt, or PBKDF2 (Password-Based Key Derivation Function 2).
Computationally Expensive, Adaptive Hashing Functions
"Adaptive" means the algorithm has a tunable cost, a work factor, an iteration count, a memory requirement, that you can raise over time as hardware gets faster. The same algorithm that took 250 milliseconds per hash on today's servers can be retuned in a year or two to keep taking 250 milliseconds on next year's faster hardware, without ever changing which algorithm you use.
OWASP puts Argon2id first, a minimum configuration of 19 MiB of memory, an iteration count of 2, and a parallelism of 1. If Argon2id is not available on your platform, it recommends scrypt with a CPU/memory cost of 2¹⁷, a block size of 8, and a parallelism of 1. bcrypt is listed only as a legacy option, with a minimum work factor of 10 and a hard input limit of 72 bytes. If your environment requires FIPS-140(Federal Information Processing Standard) validated cryptography, the fallback is PBKDF2 with at least 600,000 iterations of HMAC-SHA-256.
If you are starting a new project today, reach for Argon2id first. bcrypt is still safe when configured correctly, but it is no longer OWASP's top recommendation, and there is no reason to default to the legacy option on greenfield work.
That said, greenfield mean (brand new project). If you're maintaining a codebase where bcrypt is already deployed everywhere and nothing is forcing a change, I wouldn't prioritize ripping it out. You can't rehash existing rows without the plaintext password, so any real migration has to be opportunistic: verify with the old algorithm at login, then rehash into Argon2id at that moment, and let the database migrate itself gradually as users sign in. Treat it as a background migration you wire up once, not a sprint item, unless an actual incident or compliance deadline is pushing you.
// bcrypt — widely supported, still safe, no longer OWASP's first choice
import bcrypt from "bcrypt";
const SALT_ROUNDS = 12; // OWASP's floor is 10; 12 gives more headroom
async function hashPassword(plainPassword: string): Promise<string> {
return bcrypt.hash(plainPassword, SALT_ROUNDS);
}
async function verifyPassword(
plainPassword: string,
storedHash: string
): Promise<boolean> {
return bcrypt.compare(plainPassword, storedHash);
}
// Argon2id — OWASP's current recommended default
import * as argon2 from "argon2";
async function hashPassword(plainPassword: string): Promise<string> {
return argon2.hash(plainPassword, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB - OWASP minimum
timeCost: 2,
parallelism: 1,
});
}
async function verifyPassword(
plainPassword: string,
storedHash: string
): Promise<boolean> {
return argon2.verify(storedHash, plainPassword);
}// bcrypt — widely supported, still safe, no longer OWASP's first choice
import bcrypt from "bcrypt";
const SALT_ROUNDS = 12; // OWASP's floor is 10; 12 gives more headroom
async function hashPassword(plainPassword: string): Promise<string> {
return bcrypt.hash(plainPassword, SALT_ROUNDS);
}
async function verifyPassword(
plainPassword: string,
storedHash: string
): Promise<boolean> {
return bcrypt.compare(plainPassword, storedHash);
}
// Argon2id — OWASP's current recommended default
import * as argon2 from "argon2";
async function hashPassword(plainPassword: string): Promise<string> {
return argon2.hash(plainPassword, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB - OWASP minimum
timeCost: 2,
parallelism: 1,
});
}
async function verifyPassword(
plainPassword: string,
storedHash: string
): Promise<boolean> {
return argon2.verify(storedHash, plainPassword);
}Try it yourself: the hashPassword functions above are the whole benchmark you don't need to take the 250ms figure on faith. Wrap either call in console.time/console.timeEnd and run it on your own machine:
console.time("hash");
await hashPassword("a-realistic-test-password");
console.timeEnd("hash");console.time("hash");
await hashPassword("a-realistic-test-password");
console.timeEnd("hash");The number above is a general OWASP-aligned target, not a universal constant. Your actual timing depends on your hardware and your chosen cost parameters, so it's worth checking against your own server before assuming the default settings are right for your traffic.
Install both with npm install bcrypt argon2 (add npm install -D @types/bcrypt for bcrypt's TypeScript types; the argon2 package ships its own). Both libraries generate a random salt per password automatically and embed it directly in the returned hash string, so you never manage salt storage yourself with either of these.
One practical detail worth knowing: bcrypt truncates any input past 72 bytes. If your sign-up form accepts long passphrases up to 128 characters, which OWASP's Application Security Verification Standard recommends supporting, a long passphrase in multi-byte UTF-8 can silently exceed that 72-byte limit and get truncated before hashing. Argon2id has no such practical limit. I cover the composition-rule side of password policy, including why length matters more than forced complexity, in a separate piece on applying OWASP's Application Security Verification Standard in your code.
Salting: Why the Same Password Must Never Produce the Same Hash Twice
A salt is a random value generated for each password and combined with it before hashing, so two users who pick the identical password end up with two completely different stored hashes. Without a salt, an attacker with a precomputed table of common password hashes, and there are enormous public tables of exactly this, can match a stolen hash instantly, no cracking required. This is precisely what made the LinkedIn breach so fast: no salt meant every match was a simple lookup.
bcrypt and Argon2id handle salting for you internally, which is why the examples above never mention a salt directly. To see the mechanism explicitly, here is the same idea built manually with Node's built-in crypto.scrypt, which does not embed a salt into its output the way bcrypt and Argon2id do, so you store it yourself:
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
import { promisify } from "util";
const scryptAsync = promisify(scrypt);
const KEY_LENGTH = 64;
async function hashPassword(plainPassword: string): Promise<string> {
const salt = randomBytes(16).toString("hex");
const derivedKey = (await scryptAsync(
plainPassword,
salt,
KEY_LENGTH
)) as Buffer;
return `${salt}:${derivedKey.toString("hex")}`;
}
async function verifyPassword(
plainPassword: string,
storedHash: string
): Promise<boolean> {
const [salt, storedKeyHex] = storedHash.split(":");
const storedKey = Buffer.from(storedKeyHex, "hex");
const derivedKey = (await scryptAsync(
plainPassword,
salt,
KEY_LENGTH
)) as Buffer;
return timingSafeEqual(storedKey, derivedKey);
}import { scrypt, randomBytes, timingSafeEqual } from "crypto";
import { promisify } from "util";
const scryptAsync = promisify(scrypt);
const KEY_LENGTH = 64;
async function hashPassword(plainPassword: string): Promise<string> {
const salt = randomBytes(16).toString("hex");
const derivedKey = (await scryptAsync(
plainPassword,
salt,
KEY_LENGTH
)) as Buffer;
return `${salt}:${derivedKey.toString("hex")}`;
}
async function verifyPassword(
plainPassword: string,
storedHash: string
): Promise<boolean> {
const [salt, storedKeyHex] = storedHash.split(":");
const storedKey = Buffer.from(storedKeyHex, "hex");
const derivedKey = (await scryptAsync(
plainPassword,
salt,
KEY_LENGTH
)) as Buffer;
return timingSafeEqual(storedKey, derivedKey);
}Two details matter here. First, the salt is stored alongside the hash in plain view, on purpose, salts do not need to be secret, they only need to be unique per password, so that identical passwords never produce identical stored output. Second, the comparison uses timingSafeEqual instead of ===. A plain string comparison returns as soon as it finds the first mismatched byte, which leaks how many leading bytes matched through response timing. timingSafeEqual always takes the same amount of time regardless of where the mismatch is, closing that side channel.
What OWASP and PCI DSS Actually Require
This is not just good practice, it is a written requirement for any business handling payment card data. PCI DSS Requirement 8.3.2 states that strong cryptography must be used to render all authentication factors, which includes passwords, unreadable both in transit and at rest, across every system component. A reversible cipher does not meet that bar the way a one-way hash does: it disguises a password temporarily, with a way back, rather than rendering it unreadable in any permanent sense. Requirement 8.3.6 additionally sets a minimum password length of 12 characters, or 8 if a system genuinely cannot support 12. I cover PCI DSS's broader scope, who it actually applies to, and how it reaches checkout-page code specifically, in a separate article on securing a checkout page for PCI DSS v4.0.1.
OWASP's own Application Security Verification Standard treats this the same way: authentication requirements are written as testable statements, not slogans, and password storage is verified against the Password Storage Cheat Sheet's approved algorithms rather than left to a code reviewer's judgment call. I mapped several of ASVS's other authentication requirements, account lockout, breached-password checks, federated login token verification, to real Express and Node code in my ASVS verification checklist piece.
What This Means for Business
Framing this purely as a compliance checkbox misses the actual risk. The real exposure is operational. Adobe's encryption key was never recovered, so full plaintext recovery never happened at scale, and the company still ended up in a class-action settlement and years of "we thought encryption was enough" reputational damage. If that key had leaked instead, every one of those 130 million-plus passwords would have been recoverable in plaintext, simultaneously, including whatever other accounts customers reused them on.
Under PCI DSS Requirement 8.3.2, storing authentication factors in a reversible form is itself a finding, independent of whether a breach ever occurs. An assessor does not need a breach to flag it; a code review of the storage layer is enough. The cost of doing this correctly is a few lines of code and roughly 100 to 300 milliseconds of added latency per login, the time Argon2id or bcrypt is designed to take. The cost of getting it wrong is mandatory breach notification, a forensic audit, and a headline where the word "encrypted" turns out to mean the company's mistake, not its safeguard.
Key Takeaways
- Encryption is reversible by design; hashing is not, and a login check only ever needs a one-way comparison, never the original password back.
- Fast hashes, SHA-256, SHA-1, MD5, are crackable at billions of guesses per second on commodity GPUs and must never be used alone for passwords.
- Use Argon2id first. Fall back to bcrypt or scrypt only where Argon2id is unavailable, and always use a unique, random salt per password.
- If you're on legacy bcrypt with no forcing incident, migrate opportunistically at login rather than treating it as urgent.
- PCI DSS Requirement 8.3.2 and OWASP's Password Storage Cheat Sheet both point at the same answer: strong, slow, salted, one-way hashing, not encryption.
- The cost of doing this right is a few hundred milliseconds per login. The cost of doing it wrong is a breach notice where "encrypted" turns out to have meant "reversible."
For the correct use of encryption, for data you genuinely need to read back, see Encrypting API Responses End-to-End: What Most Backends Get Wrong
References: OWASP Password Storage Cheat Sheet; PCI DSS v4.0.1 — KPMG Overview.