Passwords, Hashing and argon2

Picture the worst Tuesday of your career. Someone found a SQL injection hole in an old reporting endpoint, and now a copy of your users table is being traded on a forum. You cannot un-leak it. That ship has sailed.

The only question left is what the attacker got. If your password column holds text they can read, every one of your users is compromised right now, and so is every other site where they reused that password. If it holds a properly salted, memory-hard hash, the attacker has a pile of expensive-to-crack noise and you have time to force a reset before much damage is done.

That is the whole game. You will not stop every breach. Your job is to make the stolen rows worthless. Here is how.

Hashing is not encryption, and neither is encoding

Three words get mixed up constantly, and the mix-up leads straight to storing passwords wrong. They are not interchangeable.

Encoding rearranges bytes into a different alphabet. Base64 is encoding. It carries no secret and anyone can reverse it in one function call. atob("aHVudGVyMg==") gives you back hunter2. Encoding is for transport, never for protection.

Encryption scrambles data with a key so that only someone holding the key can get the original back. It is reversible by design. That sounds like security, and for data you genuinely need to read again (a user’s saved API token, a shipping address) it is exactly right. For passwords it is a trap: if your server can decrypt the password to check it, then anyone who steals the server, the key, or a memory dump can decrypt it too. You have just built an expensive plaintext store.

Hashing runs the input through a one-way function. Same input, same output, every time. But given the output, there is no key and no shortcut that turns it back into the input. You verify a login by hashing what the user typed and checking it matches the stored hash. You never recover the original. That one-way property is the whole point.

Fast hashes are the wrong tool

Here is where good intentions go to die. You reach for a hash, you already know SHA-256, so you write sha256(password) and feel responsible. You are not. You have made cracking easy.

SHA-256 and MD5 were built to be fast. That is a virtue when you are checksumming a 4 GB file. It is a catastrophe for passwords, because the attacker who stole your table wants to try billions of guesses, and a fast hash lets them.

The numbers are not close. A single modern gaming GPU chews through north of a hundred billion MD5 hashes per second and tens of billions of SHA-256. Put eight of them in a box and rent it by the hour. Against a fast unsalted hash, most human-chosen passwords fall in minutes, and the common ones fall instantly because the attacker precomputed them years ago.

how you stored itcrack time, one GPUPlaintextstored: hunter2instantSHA-256, no saltstored: 9f86d081… one fast roundsecondsargon2id + per-user saltstored: $argon2id$v=19$m=19456…centuries
Three ways to store the same password, and how long one stolen row survives a GPU. Fast is the attacker's friend.

The fix has two parts, and you need both. Make every hash unique so precomputed attacks die, and make every guess expensive so brute force crawls. Salts do the first. Slow, memory-hard hashes do the second.

Salt every password

A salt is a chunk of random bytes, generated fresh for each user, mixed into the password before hashing. You store it right next to the hash. It is not a secret. Its job is not secrecy.

Its job is to make two things true. First, two users who both picked 123456 end up with completely different stored hashes, so an attacker cannot spot repeats or crack them as a batch. Second, the attacker cannot use a rainbow table, a giant precomputed map of hash back to password. That table was built for bare, unsalted hashes. Salt the input and every entry misses.

Without a saltalice H(123456) = 8d969eef91bob H(123456) = 8d969eef91same hash, one rainbow-table hit cracks bothWith a per-user saltalice H(7f2a.. + 123456) = 3b1c90aa2dbob H(9e3d.. + 123456) = c40de5f1b8every hash differs, precomputed tables are useless
Without a salt, identical passwords share a hash and one rainbow-table hit cracks both. A per-user salt makes every hash unique, so precomputed tables are dead weight.

You do not manage salts by hand. Every real password library generates a random salt for you, prepends it to the output, and reads it back automatically when you verify. That is why a stored hash looks like a long structured string rather than raw hex: the salt is baked in. More on that format shortly.

Salting kills precomputation. It does nothing to slow down a single targeted guess. For that you need the hash itself to be slow.

Slow on purpose: argon2, bcrypt, scrypt

A password hashing function is deliberately, tunably expensive. You dial in how much CPU time and memory each hash costs. On your server that cost is paid once per login and nobody notices a few hundred milliseconds. For an attacker running billions of guesses, that same cost is a wall.

The best of these are memory-hard: each hash needs a big scratchpad of RAM to compute. This matters because an attacker’s advantage comes from parallelism. A GPU has thousands of tiny cores, an ASIC has more. Force every single guess to also claim, say, 19 MiB of memory, and suddenly those thousands of cores are fighting over memory bandwidth instead of running flat out. Memory is the great equalizer between your one server and their warehouse of silicon.

Three names matter in 2026.

argon2id, the default you should reach for

Argon2 won the Password Hashing Competition in 2015 and has been the front-runner ever since. It comes in three flavors. argon2d is fastest but leaks timing through data-dependent memory access. argon2i resists that but is weaker against time-memory tradeoffs. argon2id runs the first pass in i mode and the rest in d mode, getting the best of both. For password storage, use argon2id. It is the current recommendation from essentially every security body.

It has three knobs.

argon2id cost parametersmemory m = 19456 KiB (19 MiB)each guess needs its own big RAM scratchpad, which starves GPUs and ASICspasses t = 2how many times argon2 sweeps that memory, a straight cost multiplierparallelism p = 1lanes computed together, and work the attacker must replicate per guessattacker throughput is roughly proportional to 1 / (memory x passes)
The three argon2id cost knobs. Memory is the one that specifically starves GPUs and ASICs, because every guess needs its own scratchpad.

Current OWASP guidance gives you a ladder of equivalent settings, so you can trade memory for passes depending on what your server has spare. The baseline is m = 19456 KiB (19 MiB), t = 2, p = 1. If you have more memory to spend, m = 47104 (46 MiB), t = 1, p = 1 is stronger. If you have less, climb passes as you drop memory: 12 MiB at t = 3, 9 MiB at t = 4, 7 MiB at t = 5. More memory beats more passes when you can afford it, because memory is what hurts the attacker’s hardware most.

In Node, the well-maintained library is argon2. Its defaults already sit above the OWASP floor, but set them explicitly so a future library update cannot quietly change your security posture:

import argon2 from "argon2";

async function hashPassword(password) {
  return argon2.hash(password, {
    type: argon2.argon2id,
    memoryCost: 19456, // KiB, so 19 MiB
    timeCost: 2,       // passes
    parallelism: 1,
  });
}

// later, on login
const ok = await argon2.verify(storedHash, password); // true / false

Notice you never touch a salt. argon2.hash generates 16 random bytes, mixes them in, and writes them into the output string. argon2.verify reads them back out. What comes out of hash is a self-describing PHC string, and it is worth knowing how to read one.

$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$R2Fyb24yaXNzalgorithmversioncost parameterssalthashone column holds it all, no separate salt or parameters fields to managebcrypt uses the same idea in a shorter string that starts with $2b$
An argon2id hash is a self-describing PHC string. The recipe rides along with the hash, so verify and rehash read the parameters straight from it.

That self-describing format is the reason you can raise your cost settings later and still verify old logins: the parameters that made each hash travel with it. We will use that in a moment for rehashing.

bcrypt: still fine, and everywhere

bcrypt predates argon2 by two decades and is not deprecated. It is battle-tested, it is in every language, and if you already have a system running it there is no emergency to migrate. Reach for argon2id on greenfield work, but bcrypt at a decent cost factor is a perfectly respectable choice.

import bcrypt from "bcrypt";

const hash = await bcrypt.hash(password, 12);      // 12 = cost factor
const ok = await bcrypt.compare(password, hash);   // note the argument order

The cost factor is logarithmic: each step doubles the work. OWASP says use at least 10; 12 is a common 2026 default and lands around a couple hundred milliseconds on typical cloud hardware. Measure on yours.

bcrypt has one sharp edge you must respect. It only looks at the first 72 bytes of the input. Everything past byte 72 is silently ignored. On its own that mostly means very long passphrases lose their tail. Combined with a common workaround it turns into an auth bypass.

scrypt and PBKDF2

scrypt is the other memory-hard option and it is built into Node, no dependency required. OWASP’s floor is a CPU/memory cost of N = 2^17 (131072), block size r = 8, parallelism p = 1. Node’s default N is only 2^14 and its default memory ceiling is 32 MiB, so if you follow the recommendation you must raise maxmem or the call throws:

import { scrypt, randomBytes } from "node:crypto";
import { promisify } from "node:util";

const scryptAsync = promisify(scrypt);

async function hashScrypt(password) {
  const salt = randomBytes(16);
  const N = 2 ** 17, r = 8, p = 1;
  const key = await scryptAsync(password, salt, 32, {
    N, r, p,
    maxmem: 256 * 1024 * 1024, // 256 MiB, room for N=2^17
  });
  return `scrypt$${N}$${r}$${p}$${salt.toString("hex")}$${key.toString("hex")}`;
}

Notice you now store the salt and parameters yourself, because Node hands you a raw key, not a PHC string. That extra bookkeeping is a real reason to prefer the argon2 or bcrypt libraries, which handle it for you.

PBKDF2 is the weakest of the bunch: it is CPU-hard but not memory-hard, so it holds up worse against GPUs. Its one reason to exist is FIPS-140 compliance, where it is often the only approved option. If you need it, OWASP says 600,000 iterations of HMAC-SHA-256 or more. If you do not need it, skip it.

Feel the difference

Numbers on a page do not land the way a slider does. Pick a hash type and a password shape below and watch the estimated time-to-crack move. The rates are deliberate order-of-magnitude figures for a single modern GPU, not a benchmark, but the gaps between them are honest and that is the lesson.

interactiveCrack-time estimator (illustrative)

Two things jump out if you play with it. A short password is doomed no matter which hash you pick. And switching from a fast hash to argon2id buys you many orders of magnitude for free, without asking users to do anything at all.

Peppers, briefly

A pepper is a secret added to every password before hashing, like a salt but shared across all users and, crucially, not stored in the database. It lives in your application config or a secrets manager or an HSM. The idea is defense in depth: if an attacker steals only the database and not the pepper, even weak passwords stay out of reach, because they are missing an ingredient.

Peppers are a nice extra, not a substitute for anything above. Add one by using it as the HMAC key when you pre-hash, exactly like the bcrypt example. Two honest caveats. If your app server is compromised the pepper leaks with it, so it only helps against database-only breaches. And rotating a pepper is genuinely painful, because you cannot re-pepper hashes you cannot reverse, so you end up carrying versions. Use one if you have somewhere truly separate to keep it. Do not lose sleep if you do not.

Verifying without leaking timing

You never compare password hashes with ===. Ever. Use the library’s own check: argon2.verify(hash, password) or bcrypt.compare(password, hash). Two reasons.

The obvious one: those functions know how to parse the PHC string, pull out the salt and parameters, re-run the hash the same way, and compare. A raw === would need you to reproduce all of that by hand.

The subtle one: a naive comparison leaks timing. If your compare bails out at the first byte that differs, then a wrong guess that shares more leading bytes with the real value takes microscopically longer to reject. Measure enough attempts and an attacker can recover a secret one byte at a time. This is a real, exploited class of bug. The defense is constant-time comparison: an algorithm that always takes the same time regardless of where the mismatch is. The library’s verify already does this internally, which is another reason to use it and never hand-roll the check.

When you do compare secrets by hand, session tokens, API keys, webhook signatures, use a constant-time primitive. In Node that is crypto.timingSafeEqual:

import { timingSafeEqual } from "node:crypto";

function safeEqual(a, b) {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  // timingSafeEqual throws if the lengths differ, so guard first
  if (ab.length !== bb.length) return false;
  return timingSafeEqual(ab, bb);
}

That length guard is not optional. timingSafeEqual throws a RangeError if the two buffers are different lengths, and it is not constant-time with respect to length anyway. For fixed-length values like hex tokens that is fine. For anything variable, hash both sides to a fixed length first, then compare the digests.

Now zoom out to the whole login. The compare is one step; around it you want a rate limiter in front and a single, unrevealing error behind.

POST /login (email, password)rate-limit gateper IP and per account429 too many triesfind user WHERE email = $1verify(hash, password)constant-time, inside the libraryno such user? verifya decoy so timing matchesmatch, start a sessionrehash if you raised the cost401, one generic errorinvalid email or password
A login worth shipping: rate-limit before you spend CPU, verify in constant time, and return one generic error whether the email or the password was wrong.

That decoy-verify detail on the right is easy to miss and worth doing. If you skip the hash when the email is not found, a “no such user” reply comes back noticeably faster than a “wrong password” one, and an attacker uses that gap to enumerate which emails have accounts. Run a throwaway verify against a fixed dummy hash even when the user does not exist, so both paths cost about the same:

const DUMMY_HASH = "$argon2id$v=19$m=19456,t=2,p=1$YWJjZGVmZ2hpamts$c29tZWR1bW15aGFzaHZhbHVl";

async function login(email, password) {
  const user = await db.query(
    "SELECT id, password_hash FROM users WHERE email = $1",
    [email],
  ).then((r) => r.rows[0]);

  // verify against a decoy when the user is missing, so timing does not leak existence
  const hash = user?.password_hash ?? DUMMY_HASH;
  const ok = await argon2.verify(hash, password);

  if (!user || !ok) {
    // exact same response for "no account" and "wrong password"
    throw new HttpError(401, "Invalid email or password");
  }
  return startSession(user.id);
}

Rehash when you raise the cost

Security settings that felt strong three years ago feel weak today, because hardware got faster. When you bump your argon2 memory or your bcrypt cost, you cannot re-hash everyone at once, because you do not have their passwords. You only have hashes. So you upgrade lazily, on the one occasion you briefly hold the plaintext: a successful login.

The PHC string makes this clean. It records the parameters each hash was made with, so you can ask “was this made with weaker settings than I use now?” and, if so, quietly re-hash with the new cost before you forget the password:

const CURRENT = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };

if (await argon2.verify(user.password_hash, password)) {
  if (argon2.needsRehash(user.password_hash, CURRENT)) {
    const upgraded = await argon2.hash(password, CURRENT);
    await db.query(
      "UPDATE users SET password_hash = $1 WHERE id = $2",
      [upgraded, user.id],
    );
  }
  return startSession(user.id);
}

bcrypt has the same move: read the cost out with bcrypt.getRounds(hash) and re-hash if it is below your target. Over a few weeks of normal logins, your active users drift onto the stronger settings with nobody noticing.

A password policy that reflects 2026 guidance

Most password rules you have suffered under are actively harmful. The forced quarterly rotation, the “must contain one uppercase, one number, one symbol”, the 16-character maximum: those come from folklore, and current NIST guidance (SP 800-63B, Revision 4) has thrown them out. Here is what the evidence actually supports.

Length beats complexity. A long passphrase like correct horse battery staple is far harder to crack than P@ss1! and far easier to remember. NIST now requires verifiers to allow at least 64 characters and to accept all printable ASCII, spaces, and Unicode, and it forbids composition rules. Set a generous maximum, not a stingy one. (Do keep a sane upper bound, a few hundred characters, so nobody uploads a novel to exhaust your hashing CPU.)

Check against breached lists, not character classes. The single most useful screen is rejecting passwords already known to be compromised. The clever part is you can do it without ever sending the full password anywhere. Hash it with SHA-1, send only the first five hex characters to a range API, and check the returned suffixes locally. This is k-anonymity: the service never learns which password you asked about.

import { createHash } from "node:crypto";

// Pwned Passwords range API: only the 5-char prefix ever leaves your server
async function isBreached(password) {
  const sha1 = createHash("sha1").update(password).digest("hex").toUpperCase();
  const prefix = sha1.slice(0, 5);
  const suffix = sha1.slice(5);

  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
  const body = await res.text();
  return body.split("\n").some((line) => line.split(":")[0].trim() === suffix);
}

Do not force periodic rotation. NIST is blunt here: you SHALL NOT require routine password changes. Forced rotation just pushes people to Summer2026! then Autumn2026!, which is weaker, not stronger. Force a reset only when you have actual evidence of compromise.

Allow paste, and let password managers work. Blocking paste on a password field breaks password managers and pushes users toward short, typeable, guessable passwords. NIST says permit paste. So does common sense. The goal is to encourage long random secrets, and password managers are how normal people manage those.

Don’t help the attacker: errors, logs, rate limits

The hashing can be flawless and you can still leak your way to a breach around the edges.

Never log the password. Not in access logs, not in a “debugging” console.log, not in an error report that serializes the whole request body. It is astonishing how often a plaintext password ends up in a log aggregator that a hundred employees can search. Scrub password fields before anything touches your logging pipeline, and treat the raw value as radioactive: hash it, then let it go.

Keep error messages vague. “No account with that email” tells an attacker exactly which addresses to target. “Password incorrect” confirms an account exists. Return the same generic “Invalid email or password” for both, and pair it with the decoy-verify trick above so the timing does not give away what the message hides.

Rate-limit and lock out sensibly. Even the slowest hash falls to unlimited online guessing if you let someone try a million times. Throttle by IP and by account, add exponential backoff, and lock an account temporarily after a burst of failures. Doing this without accidentally building your own denial-of-service (an attacker locking out real users on purpose) takes a little care, which is its own topic in rate limiting.

The best password code is none

Here is the closing thought, and it is not a cop-out. Every section above is a landmine you now have to keep swept forever: the hash tuning drifts out of date, the reset flow leaks, the breach-list check goes stale, the reset-token email becomes its own attack surface. Passwords are a genuinely hard thing to do well, and the reward for doing them well is that you have merely avoided disaster.

So ask whether you need to store passwords at all. For a great many apps, the answer is no. Let users sign in through an identity provider with OAuth, and their password becomes Google’s or GitHub’s problem, hashed by a team whose whole job is this. Or send a one-time link to a verified email and skip the shared secret entirely with magic links. Both move the hardest, highest-stakes part of authentication off your plate.

If you do handle passwords, do it the way this article lays out: argon2id, a per-user salt the library manages, a cost tuned to your hardware, constant-time verification, a rate limiter, quiet generic errors, and a policy that trusts length over theater. And if you do not have to, the safest password database is the one you never built.

Summary

  • If you can read a stored password, you have already lost. Assume the database leaks and make the leaked rows useless.
  • Hashing, encryption, and encoding are different. Passwords are a comparison problem, so use a one-way hash, never reversible encryption and never base64.
  • Fast hashes (MD5, SHA-256) are the wrong tool. A single GPU does billions per second, so their speed is the attacker’s advantage. You need slow and memory-hard.
  • Salt every password with unique per-user random bytes stored alongside the hash. This defeats rainbow tables and makes identical passwords hash differently.
  • argon2id is the 2026 default: OWASP baseline m = 19 MiB, t = 2, p = 1, with a ladder trading memory for passes. bcrypt is still fine (mind the 72-byte limit); scrypt is built into Node; PBKDF2 only for FIPS.
  • Verify with the library’s own compare, never ===, to parse the PHC string correctly and to stay constant-time. Use crypto.timingSafeEqual for other fixed-length secrets.
  • Rehash lazily on login when you raise the cost, reading the old parameters from the self-describing hash.
  • Modern policy: length over complexity, screen against breached lists, no forced rotation, allow paste and long passphrases, rate-limit logins, and keep errors generic.
  • Best of all, consider skipping passwords via OAuth or magic links so someone else carries the risk.