Security for Developers

Passwords and Login: Hashing, Sessions, MFA and Rate Limits

Why you never store passwords, hashing with a salt (scrypt and Argon2), safe sessions and JWT mistakes, password reset flows, MFA, and stopping brute force.

Lesson 0316 min read#security#auth#passwords#sessions#jwt

On this page

  1. Words used in this lesson
  2. Why you never store a password
  3. Fast hashes are not enough
  4. The fix: a slow hash with a random salt
  5. Password rules that help (and rules that don't)
  6. Do not say which half was wrong
  7. Sessions and cookie flags
  8. JWT: what it is and how it goes wrong
  9. Password reset flow
  10. MFA: a second proof
  11. Slow down guessing: rate limits and lockout
  12. Try it
  13. Interview questions
  14. Key takeaways
  15. What's next

In one line: never store a password, store a slow salted hash of it; keep the login session in a random cookie with the right flags; treat a JWT as a signed note anyone can read; send reset links that are random, single use and short lived; add a second factor; and slow down guessing with rate limits.

Lesson 2 was about attackers who run code in your page (XSS) or borrow your cookies (CSRF). This lesson is about the front door: how people prove who they are. In the OWASP Top 10 this is the category called A07:2025 Authentication Failures, and it touches A04:2025 Cryptographic Failures too, because bad password storage is a crypto mistake.

We will build every piece in small plain Node.js files and run them: a fast hash that gets cracked in seconds, a slow salted hash with scrypt, a session cookie, a signed token, a reset flow, a one-time code (TOTP) checked against the official test values, and a tiny rate limiter. Lesson 4 then covers what happens after login: checking what each user is allowed to do.

If you want the browser side of sessions and tokens, read Browser Lesson 2 (Sessions vs JWT) and Browser Lesson 3 (Where do tokens live?). For the server code view, see Backend Lesson 6 (Authentication and Authorization on the Server), and for OAuth and login libraries see Full-Stack Lesson 22.

What I ran / checked. Everything ran with Node.js v22.22.0 on Linux, using only the built-in node:crypto, node:http and node:util modules. No npm packages. The files were: a fast-hash cracking test, scrypt hash and verify, a cookie header test through a local HTTP server, an HS256 token sign and verify, a reset-token flow, a TOTP generator, and an in-memory rate limiter. All output below is real, pasted from those runs. Timings and random values (salts, tokens) change on every run. Not run: Argon2id. The Node docs say crypto.argon2() was added in v24.7.0, so it does not exist in the v22 I used. Sources I read: the OWASP Cheat Sheet Series (Password Storage, Authentication, Session Management, Forgot Password, Multifactor Authentication, Cryptographic Storage; read from the OWASP/CheatSheetSeries repository on GitHub), the OWASP Top 10:2025 introduction (from the OWASP/Top10 repository), and the Node.js crypto API docs (scrypt, timingSafeEqual, randomBytes, argon2). The RFC 6238 test values came from my own memory of the RFC appendix; the code reproduced all of them, which is the check. Could not reach: the live OWASP and NIST web pages (owasp.org, pages.nist.gov) and PortSwigger Academy pages were blocked from my machine. The NIST SP 800-63B rules below are taken from how the OWASP Authentication cheat sheet quotes them, and the JWT "alg none" attack is described from a PortSwigger knowledge-base summary I found by search. I did not run any attack against a system that is not my own toy code.

Words used in this lesson

  • Authentication means proving who you are (logging in). Authorization means checking what you may do (lesson 4).
  • A hash is a one-way fingerprint of some data. The same input always gives the same output, and you cannot turn the output back into the input.
  • A salt is a random value, different for every user, that you mix into the password before hashing. It is not secret. It makes every hash unique.
  • Brute force means trying many guesses until one works. A wordlist attack tries the passwords people commonly use first.
  • A slow hash (also called a password hashing function or KDF) is a hash made on purpose to be slow and memory hungry, so each guess costs the attacker time. Examples: Argon2id, scrypt, bcrypt, PBKDF2.
  • A session is the server remembering that you logged in. The browser holds a random session ID in a cookie.
  • A cookie flag is a setting on a cookie: HttpOnly, Secure, SameSite.
  • A JWT (JSON Web Token) is a text token with three parts (header, payload, signature). The payload is readable by anyone. The signature stops changes.
  • HMAC is a way to make a signature using a shared secret key and a hash.
  • A reset token is a random value in an email link that lets someone choose a new password.
  • MFA (multi-factor authentication) means proving yourself with two or more kinds of proof: something you know, have, or are.
  • TOTP (time-based one-time password) is the 6-digit code from an authenticator app. It changes every 30 seconds.
  • Rate limiting means allowing only so many tries in a period of time. Lockout means blocking after too many failures.
  • Credential stuffing means trying leaked email and password pairs from other sites' breaches on your site.

Why you never store a password

Say your database has a users table:

emailpassword
alice@example.comsunshine
bob@example.comletmein

If someone gets a copy of this table (a SQL injection from lesson 1, a lost backup, a bad cloud setting, a rogue employee), they now have every password. Many people reuse a password on other sites, so the damage spreads past your app. The OWASP Password Storage cheat sheet starts with a clear rule: passwords must never be stored in plain text. They must be protected with strong, slow hashing.

Your app never needs to read a password back. It only needs to answer one question at login: "is the password this person typed the same as the one they set?" A one-way hash answers that question without keeping the password.

Encryption is the wrong tool here. Encryption can be undone with the key. If the key leaks with the database, all passwords leak. OWASP says encryption for passwords is for rare edge cases only, such as when your app must log in to another system for the user.

Fast hashes are not enough

A first idea: store sha256(password). SHA-256 is a good hash for many jobs. It is a bad hash for passwords, and the reason is speed. A fast hash lets an attacker make a huge number of guesses per second.

Here is a toy leak. Four users, hashed with plain SHA-256. I act as the attacker with a small wordlist of common passwords plus one million made-up guesses:

JavaScript
import { createHash } from "node:crypto";const sha256 = (s) => createHash("sha256").update(s).digest("hex");const users = { alice: sha256("sunshine"), bob: sha256("letmein"),                carol: sha256("x7#Qv9!pLm2$zR"), dave: sha256("sunshine") };console.log(users);const common = ["123456", "password", "qwerty", "letmein", "sunshine", "iloveyou"];const guesses = [...common];for (let i = 0; i < 1_000_000; i++) guesses.push("guess" + i);const lookup = new Map();                    // hash -> guessconst t = performance.now();for (const g of guesses) lookup.set(sha256(g), g);const ms = performance.now() - t;console.log(`hashed ${guesses.length} guesses in ${ms.toFixed(0)} ms`);for (const [u, h] of Object.entries(users))  console.log(u, lookup.has(h) ? "CRACKED: " + lookup.get(h) : "not in list");

Real output:

TEXT
{  alice: 'a941a4c4fd0c01cddef61b8be963bf4c1e2b0811c037ce3f1835fddf6ef6c223',  bob: '1c8bfe8f801d79745c4631d09fff36c82aa37fc4cce4fc946683d7b336b63032',  carol: '9a8e2e24eae8493e61f9bcf3b556094f8627aaa58cd2328dc592d2de7b83e305',  dave: 'a941a4c4fd0c01cddef61b8be963bf4c1e2b0811c037ce3f1835fddf6ef6c223'}hashed 1000006 guesses in 3000 ms (333363 per second)alice CRACKED: sunshinebob CRACKED: letmeincarol not in listdave CRACKED: sunshinemd5: 1,000,000 hashes in 1104 ms

Read this slowly, because it shows three separate problems:

  1. Speed. One JavaScript thread on my machine made about 330,000 SHA-256 guesses a second, and it also stored them in a map. Dedicated cracking programs on graphics cards are far faster than this (I did not measure that; it is the reason OWASP says fast hashes are unsuitable). An old hash like MD5 is just as quick (one million in about 1.1 seconds here) and is also broken for other reasons.
  2. Same password, same hash. Look at alice and dave. Both chose sunshine, and both rows hold the exact same string. An attacker cracks one and gets both. They can also see "these two users share a password" without cracking anything.
  3. Precomputed tables. Because the hash of sunshine is always a941..., people can build a big table of common password hashes once and look up any leaked hash for free. Such tables are called rainbow tables or lookup tables.

Notice that carol, who chose a long random password, survived. The weak passwords fell. A hash cannot rescue a bad password, but a slow, salted hash makes every guess expensive, so the attacker can only afford to try a few.

"I hash with SHA-256 and a secret string added to the end" is still a fast hash. Adding one fixed string for all users is not a salt. You need a slow function and a new random salt per user.

The fix: a slow hash with a random salt

Two ideas fix the problems above.

A salt is 16 or more random bytes, new for every password, saved next to the hash. It is not a secret. Its only job is to make alice and dave produce different hashes, and to make any precomputed table useless.

A slow, memory-hungry function makes each guess cost real time and real memory. OWASP's current first choice is Argon2id. If it is not available, use scrypt. Use bcrypt only on older systems. If you need FIPS compliance, use PBKDF2. These are the minimum settings listed in the OWASP Password Storage cheat sheet when I read it:

FunctionMinimum setting from OWASP
Argon2id19 MiB memory, 2 iterations, 1 parallelism (other equal trade-offs are listed, such as 46 MiB with 1 iteration)
scryptcost N = 2^17 (about 128 MiB), block size r = 8, parallelism p = 1 (other equal trade-offs are listed)
bcryptwork factor 10 or more, and a 72 byte password limit
PBKDF2600,000 or more iterations, with HMAC-SHA-256

Node has scrypt built in (node:crypto). Argon2 was added to node:crypto in Node 24.7.0, per the Node docs. On older Node you need a library for Argon2id. Here is scrypt with the OWASP setting:

JavaScript
import { scrypt, randomBytes, timingSafeEqual } from "node:crypto";import { promisify } from "node:util";const scryptAsync = promisify(scrypt);const P = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };const KEYLEN = 64;export async function hashPassword(pw) {  const salt = randomBytes(16);                       // new random salt  const key = await scryptAsync(pw.normalize("NFKC"), salt, KEYLEN, P);  return ["scrypt", P.N, P.r, P.p, salt.toString("base64"), key.toString("base64")].join("$");}export async function verifyPassword(pw, stored) {  const [alg, N, r, p, saltB64, keyB64] = stored.split("$");  if (alg !== "scrypt") return false;  const expected = Buffer.from(keyB64, "base64");  const actual = await scryptAsync(pw.normalize("NFKC"), Buffer.from(saltB64, "base64"),    expected.length, { N: +N, r: +r, p: +p, maxmem: P.maxmem });  return timingSafeEqual(actual, expected);           // constant-time compare}

Real output of hashing the same password twice, then checking:

TEXT
hash took 680 msscrypt$131072$8$1$G+hMLuI8hpSuv7nTejRlfA==$AqSZ2pe7USP3AfgNmXg8yqAn4wSgnZOOIhlP9rp4w8H9w79PPmg59RoKzjPV5sGrU0eUzO/wJMXF4MutXeLIvA==scrypt$131072$8$1$5NyFzzvikvxhthqNny72HA==$3Lf4+3keQIRIbbc+IAYvoykpPDuc37xogcoqtOexCyhifXbkiTnCm7SrvXVEVQo9vWvhzGq+yk8C+RA9CK5m3A==same password, same stored string? falseright password: truewrong password: false

What to notice:

  • Same password, different stored strings. The two salts differ, so the two hashes differ. That fixes the alice and dave problem.
  • The stored string keeps its own settings. It holds the name, the N, r, p, the salt and the hash. Later you can raise the cost for new passwords, and old ones still verify with the settings they were made with. This is the same idea as the $argon2id$... strings you see from libraries.
  • It took about 680 ms on my machine. That is the point. One login costs you a fraction of a second. An attacker who needs billions of guesses pays that price for each one. Timings differ on your hardware. Test on your real server and pick a cost that keeps logins fast enough under your expected traffic (OWASP says the same: tune and benchmark).
  • timingSafeEqual compares two byte buffers in a way that takes the same time whether they differ at the first byte or the last. A normal === can stop early, and in theory the time difference leaks information. It throws if the lengths differ, which I confirmed:
TEXT
timingSafeEqual length mismatch: ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH

The memory trap. Node limits scrypt memory by default. With the OWASP setting and no maxmem, I got an error, not a hash:

TEXT
default maxmem: ERR_CRYPTO_INVALID_SCRYPT_PARAMS - Invalid scrypt params: error:030000AC:digital envelope routines::memory limit exceeded

So you must raise maxmem on purpose. Also, Node's own defaults are weaker than the OWASP minimum: a call with no options uses N=16384 and took only about 59 ms in my run. Do not rely on defaults. State N, r, p yourself.

Two more habits from OWASP: hash the password after a Unicode normalization (the normalize("NFKC") above) so the same typed text always gives the same bytes, and never log the password anywhere. Also remember bcrypt only reads the first 72 bytes, which is why OWASP lists that limit.

Pepper (optional extra)

A pepper is a secret value added to all passwords, kept outside the database (in a secret store). If an attacker copies only the database, the pepper stops them from cracking hashes at all. It is a bonus layer, not a replacement for salt and slow hashing. OWASP also warns that you cannot change a pepper without making users reset their passwords.

Password rules that help (and rules that don't)

Many sites force "one capital, one number, one symbol" and a change every 90 days. The OWASP Authentication cheat sheet, quoting NIST SP 800-63B, says to do something different:

  • Minimum length: at least 8 characters when MFA is on, and longer is better. Allow a maximum of at least 64 so people can use passphrases.
  • Allow every character, including spaces and Unicode. No composition rules (no forced capitals or symbols).
  • Block common and previously breached passwords. The "Pwned Passwords" service is the example OWASP gives.
  • Do not force regular changes. Force a change when you learn of a leak.

Long passphrases like correct horse battery staple are easy to remember and hard to guess. A rule that forces P@ssw0rd1! makes people pick a pattern attackers already try first.

Do not say which half was wrong

If your login says "no such email" for one case and "wrong password" for another, an attacker can test which emails have accounts. This is called user enumeration. OWASP says to answer the same way in every case:

TEXT
Wrong:  "No account with that email."   /   "Wrong password."Right:  "Email or password is incorrect."

The same applies to the reset page ("If that email exists, we sent a link") and the sign-up page. Also keep the time similar: a path that skips the hash returns much faster and gives the secret away. A simple trick is to run a dummy hash when the user is not found.

After a correct login the server makes a session ID: a long random string. It saves "this ID belongs to alice" on the server and sends the ID to the browser in a cookie. The browser returns it with each request. (Browser Lesson 2 compares this with JWT.)

The ID must be unguessable. OWASP says it needs at least 64 bits of entropy from a cryptographically secure random generator, and suggests 128 bits or more if you make your own. In Node, randomBytes(32) gives 256 bits. Never build IDs from the time, a counter, or Math.random() (lesson 5 shows why).

Here is a tiny server that sets the cookie, and a request to it:

JavaScript
const id = randomBytes(32).toString("base64url");res.setHeader("Set-Cookie",  `__Host-sid=${id}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600`);

Real response header seen by the client:

TEXT
200 [  '__Host-sid=yBF-YYgPP2ncifjZ5Otazy0JOJzwaK5mqQwV1K_SylM; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600']
PartWhat it does
HttpOnlyJavaScript on the page cannot read this cookie (document.cookie hides it). If XSS happens (lesson 2), the script cannot simply steal the session ID. It can still use the session from inside the page.
SecureThe browser sends the cookie only over HTTPS, never over plain HTTP.
SameSite=LaxThe browser does not send the cookie on most cross-site requests. This is a main defence against CSRF (lesson 2). Strict is tighter; OWASP lists both.
__Host- prefixThe browser only accepts the cookie if it has Secure, Path=/ and no Domain. This blocks other subdomains from setting a fake one. OWASP recommends it for session IDs.
Max-AgeA lifetime. Also add an idle timeout and an absolute timeout on the server side.

Two more server rules from the OWASP Session Management cheat sheet:

  • Make a new session ID right after login (and after any privilege change). If you keep the pre-login ID, an attacker who planted it on the victim's browser now shares the logged-in session. This is session fixation.
  • Do not keep session IDs or tokens in localStorage. Any script on your page can read it. OWASP's wording is firm on this. Browser Lesson 3 shows the trade-offs.

The Secure flag does nothing if you also serve the site over plain HTTP and a user visits that. Redirect HTTP to HTTPS and turn on HSTS (lesson 5).

JWT: what it is and how it goes wrong

A JWT is three base64url pieces joined by dots: header.payload.signature. Many APIs use it as a login token. The crucial fact: base64url is not encryption. Anyone holding the token can read the payload. The signature only stops people from changing it without the key.

Here is a tiny HS256 token (HMAC with SHA-256) built from node:crypto, with a strict verifier:

JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";const b64u = (x) => Buffer.from(typeof x === "string" ? x : JSON.stringify(x)).toString("base64url");export function sign(payload, secret) {  const head = b64u({ alg: "HS256", typ: "JWT" });  const body = b64u(payload);  const sig = createHmac("sha256", secret).update(`${head}.${body}`).digest("base64url");  return `${head}.${body}.${sig}`;}export function verify(token, secret) {  const [h, p, s] = token.split(".");  const header = JSON.parse(Buffer.from(h, "base64url"));  if (header.alg !== "HS256") throw new Error("alg not allowed: " + header.alg); // pin the algorithm  const good = createHmac("sha256", secret).update(`${h}.${p}`).digest();  const got = Buffer.from(s, "base64url");  if (got.length !== good.length || !timingSafeEqual(got, good)) throw new Error("bad signature");  const claims = JSON.parse(Buffer.from(p, "base64url"));  if (typeof claims.exp !== "number" || claims.exp < Date.now() / 1000) throw new Error("expired or no exp");  return claims;}

Signing a token for user 42 with role user and a 15 minute expiry, then testing attacks on my own toy code:

TEXT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzkxNzQyNzgwfQ.XrsAKB5EYg5hznT8JxyRfvqBHxAz5EvzVyYvu59IRu8payload is just base64url, anyone can read it: { sub: '42', role: 'user', exp: 1791742780 }verify ok: { sub: '42', role: 'user', exp: 1791742780 }badDecode accepts forged role: adminverify forged: bad signatureverify alg none: alg not allowed: noneno exp: expired or no expexpired: expired or no expwrong secret: bad signature

Go through each line:

  • The payload is readable. I decoded it with no key. So never put a password, an API key, or private data in a JWT. Put an ID and the minimum else.
  • badDecode is the classic bug: code that decodes the payload and trusts it. I changed role to admin and kept the old signature, and the careless code accepted admin. The real verify said bad signature.
  • alg: none. The JWT header says which algorithm was used. The spec has a value none meaning "unsigned". An attacker sets alg to none, deletes the signature, and edits the payload. If your library trusts the header, it skips the check. PortSwigger's knowledge base describes this attack. The fix is to decide the allowed algorithm in your code, never from the token. My verify rejected it.
  • No exp (expiry). A token without an expiry lives forever. If it leaks, it works forever. Always set a short exp (minutes for an access token) and require it when you verify.
  • A weak secret can be guessed offline: the attacker keeps one token and tries many secrets until the signature matches. Use a long random secret (32 or more random bytes), not secret or dev-secret.

Other JWT habits worth knowing:

  • JWTs are hard to revoke. A "log out" does not cancel a token that is still valid. Short lives plus a refresh token, or a server-side list of revoked IDs, help. For a normal web app with one server side, a plain session cookie is often simpler and safer. Browser Lesson 2 gives the full comparison.
  • With asymmetric algorithms (RS256, ES256) the verifying server only needs the public key. A known attack mixes up the two kinds, so again: pin the algorithm.
  • Check other claims too: who issued it (iss) and who it is for (aud).
  • Use a maintained library for production, and keep it updated. My 20 lines are for learning.

Password reset flow

The "forgot password" feature is an alternate way to log in, so attackers go for it. The OWASP Forgot Password cheat sheet asks for these properties for the token:

  1. Random from a cryptographically secure generator.
  2. Long enough that guessing is hopeless.
  3. Stored safely. I store only a hash of it.
  4. Single use, and it expires soon.
  5. Don't change the account until the token is shown (so an attacker cannot lock you out by asking for resets).
  6. Give the same message whether the email exists or not.
JavaScript
const db = new Map(); // tokenHash -> { userId, expiresAt, used }function requestReset(userId, now = Date.now()) {  const token = randomBytes(32).toString("base64url");           // 256 bits  db.set(sha256(token), { userId, expiresAt: now + 15 * 60_000, used: false });  return token;                                                  // goes in the email only}function useReset(token, now = Date.now()) {  const row = db.get(sha256(token));  if (!row || row.used || row.expiresAt < now) return { ok: false };  row.used = true;                                               // single use  return { ok: true, userId: row.userId };}

Real run:

TEXT
emailed token: lvNUZwh_Do1koaWTg6Tfpv0IvlKBzu_rlafZBC0WqNEwhat the database holds: 1f22afe452d2b6d206059038c3dff70a04872572222e133a8488ea34fb2a5d83use 1: { ok: true, userId: 'alice' }use 2 (replay): { ok: false }after 16 minutes: { ok: false }guessed token: { ok: false }

Why store only the hash? If someone reads your database, they get hashes of tokens, not usable links. A plain SHA-256 is fine here (unlike for passwords) because the token is 256 random bits. There is nothing to guess from a wordlist, so speed does not help the attacker.

Other points from OWASP and general practice:

  • Build the link from your own configured domain, not from the Host header (a bad header can point the link at the attacker's site).
  • Send the link over HTTPS and rate limit the reset endpoint.
  • After a reset, end the user's other sessions and send an email saying the password changed. Never email the new password.
  • Do not use "security questions" as a recovery factor. NIST dropped them, per the MFA cheat sheet.

MFA: a second proof

MFA means you need more than one kind of proof. The OWASP MFA cheat sheet groups them as something you know (a password), something you have (a phone or a key), and something you are (a fingerprint). It also quotes Microsoft analysis that MFA blocks the large majority of password attacks.

MethodGoodWeak spot
TOTP app (6-digit code)Cheap, works offline, widely supportedA fake site can ask for the code and use it within 30 seconds (phishing)
SMS or phone call codesEasy for usersOWASP marks these as restricted; phone numbers can be hijacked
Passkeys / FIDO2 / security keysResistant to phishing, because the key only answers for the real siteNeeds device support and a recovery plan
Recovery codesA safety net when a phone is lostMust be single use and stored like passwords (hashed)

A TOTP you can build in 15 lines

TOTP is defined in RFC 6238. Both sides share a secret key. Each computes: take the current time, divide by 30 seconds to get a counter, run HMAC on the counter, and cut the result down to a few digits.

JavaScript
import { createHmac } from "node:crypto";export function hotp(secret, counter, digits = 6, algo = "sha1") {  const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(counter));  const h = createHmac(algo, secret).update(msg).digest();  const off = h[h.length - 1] & 0x0f;                       // "dynamic truncation"  const bin = ((h[off] & 0x7f) << 24) | (h[off + 1] << 16) | (h[off + 2] << 8) | h[off + 3];  return String(bin % 10 ** digits).padStart(digits, "0");}export const totp = (secret, unixSeconds, digits = 6, algo = "sha1", step = 30) =>  hotp(secret, Math.floor(unixSeconds / step), digits, algo);

How do I know it is right? The RFC publishes test values for the secret 12345678901234567890 with 8-digit codes. My code matched every one I checked:

TEXT
SHA1  T=59 94287082 OKSHA1  T=1111111109 07081804 OKSHA1  T=1111111111 14050471 OKSHA1  T=1234567890 89005924 OKSHA1  T=2000000000 69279037 OKSHA1  T=20000000000 65353130 OKSHA256 T=59 46119246 OK6-digit code for T=59: 287082live code now: 847488 (changes every 30 s)

(The 6-digit code is just the last 6 digits of the 8-digit one, 94287082, which is how truncation works.) The server stores the secret per user, compares the typed code in constant time, and usually accepts one step before and after to allow for clock drift. It must also remember the last accepted code so the same code cannot be used twice, and it must rate limit attempts, because a 6-digit code has only a million values.

Write this only to learn. In production use a maintained TOTP library, store the secret encrypted (lesson 5), and show recovery codes at setup. An MFA reset path that is weaker than the MFA itself defeats the purpose.

Slow down guessing: rate limits and lockout

Even with a slow hash, an attacker can try endless passwords against your login form, or try a list of leaked passwords across many accounts (credential stuffing). The OWASP Authentication cheat sheet suggests throttling. A tiny in-memory limiter shows the idea. Here: 5 failures per minute per key, then block.

JavaScript
export function makeLimiter({ max = 5, windowMs = 15 * 60_000 } = {}) {  const fails = new Map(); // key -> timestamps of recent failures  const recent = (key, now) => {    const a = (fails.get(key) ?? []).filter((t) => now - t < windowMs);    fails.set(key, a); return a;  };  return {    allowed(key, now = Date.now()) { return recent(key, now).length < max; },    fail(key, now = Date.now()) { recent(key, now).push(now); },    reset(key) { fails.delete(key); },    retryAfterMs(key, now = Date.now()) {      const a = recent(key, now); return a.length < max ? 0 : windowMs - (now - a[0]);    },  };}

I drove it with a fake clock, seven tries one second apart:

TEXT
try 1: password checked, wrongtry 2: password checked, wrongtry 3: password checked, wrongtry 4: password checked, wrongtry 5: password checked, wrongtry 6: BLOCKED, retry in 55stry 7: BLOCKED, retry in 54sa minute later: allowed again

Notice that when the limiter says no, the code never even checks the password. That also protects your CPU, since scrypt is expensive.

Design points from OWASP and common sense:

  • Count per account, and also per IP. Counting only per IP fails against attackers with many IPs. Counting only per account lets anyone lock out a victim on purpose. Use both, and keep a way for the real owner back in (a reset email).
  • Prefer slowing down over a hard lock. OWASP mentions exponential delays (1 second, then 2, then 4...) as an alternative to a fixed ban.
  • Log lockouts and look at them (lesson 5 covers logs).
  • This toy lives in one process's memory. With several servers or restarts, the counts reset. Real systems keep counters in a shared store such as Redis or in the gateway. Lesson 7 returns to rate limits for APIs.
  • Add a CAPTCHA only as a helper, not the main control.

Try it

  1. Run the fast-hash test with a password of your own that is NOT in the wordlist. Does it survive? Now add it to the wordlist and see.
  2. Change scrypt to N: 2 ** 14 and measure the time. Then try N: 2 ** 18 with maxmem large enough. What do you see?
  3. Hash the same password twice with hashPassword. Prove the stored strings differ and both verify.
  4. Edit the JWT payload by hand (change role) without re-signing. Make sure verify rejects it.
  5. Build a token with alg: "none" and confirm your verify rejects it.
  6. Extend the reset code so that a new reset request invalidates earlier unused tokens for the same user.
  7. Change the limiter to double the lockout time after each repeat block.

Answers

  1. A long random password stays out of any small wordlist. When you add it to the list, it is found instantly, which shows that the hash only slows guessing, it does not make a guessable password safe.
  2. Lower N is faster (and weaker). A higher N is slower and needs more memory; if maxmem is too small Node throws the memory limit error from this lesson.
  3. The salts differ, so the strings differ. Both verify with verifyPassword because each string carries its own salt and settings.
  4. The signature no longer matches the edited payload, so verify throws bad signature.
  5. verify throws alg not allowed: none because it checks the algorithm in code before it looks at the signature.
  6. Keep a userId -> tokenHash map. When you create a new token, mark the previous row used = true (or delete it).
  7. Store a strikes count per key. Set the window to baseMs * 2 ** strikes when blocking, and reset the count on a successful login.

Interview questions

How should you store passwords? (Must know)

I never store the password itself. I store a slow, salted hash made with Argon2id, or scrypt if Argon2id is not available, or bcrypt for older systems, or PBKDF2 when FIPS is needed. Each password gets its own random salt, and the settings are saved in the stored string so I can raise them later. I use the cost OWASP lists as a minimum and test it on my server so logins stay fast enough. I compare with a constant-time function.

Follow-up: "Why not SHA-256?" It is fast. A fast hash lets an attacker try a huge number of guesses per second. I measured about 330,000 per second on one JavaScript thread. A slow function makes each guess costly.

What does a salt do, and is it secret? (Must know)

A salt is a random value, new for every password, mixed into the hash. It is not secret and is stored next to the hash. It makes two users with the same password get different hashes, and it makes precomputed tables (rainbow tables) useless. It does not make guessing slower. The slow function does that.

What is a pepper? (Senior signal)

A pepper is a secret shared by all passwords and kept outside the database, for example in a secrets manager. It adds protection if only the database leaks. It must not be stored with the hashes, and changing it means resetting passwords. It is an extra layer on top of salt and slow hashing, not instead of them.

What are good password rules today? (Must know)

Based on NIST guidance as OWASP quotes it: a minimum length (at least 8 with MFA, longer is better), allow at least 64 characters and all characters, no forced composition rules, block common and breached passwords, and do not force periodic changes. Force a change only after a compromise. Also encourage password managers and MFA.

Why use timingSafeEqual? (Senior signal)

A normal comparison can return as soon as it finds a different byte, so the time depends on how much matched. In theory an attacker can measure that. crypto.timingSafeEqual takes the same time either way. It needs equal-length buffers, so I check the lengths first (or compare fixed-length hashes). I also know it is not the main protection for password login, because the slow hash already hides the compare, but it matters for comparing tokens, signatures and MACs.

What do the cookie flags do? (Must know)

HttpOnly hides the cookie from JavaScript so XSS cannot read it directly. Secure sends it only over HTTPS. SameSite limits sending on cross-site requests, which helps against CSRF. The __Host- prefix forces Secure, Path=/ and no Domain. I also set an idle and absolute timeout, and make a new session ID at login to prevent session fixation.

What are the common JWT mistakes? (Must know)

Trusting the payload without checking the signature. Accepting the algorithm named in the token (so alg: none or a key-type confusion works). Having no expiry or a very long one. Using a weak secret. Putting private data in the payload, which anyone can read. And forgetting that a JWT is hard to revoke. I pin the algorithm in code, require exp, check iss and aud, and use a long random secret.

Follow-up: "Sessions or JWT?" For a normal web app, I often choose server sessions in an HttpOnly cookie because logout and revocation are simple. I choose JWT when many separate services must verify a token without calling a central store, and I keep the lifetime short. See Browser Lesson 2.

Design a safe password reset. (Senior signal)

The user submits an email. I always show the same message. If the account exists, I create 32 random bytes, store only its SHA-256 hash with an expiry of about 15 minutes and a used flag, and email a link built from my own domain. When the link is used, I check the hash, the expiry and the used flag, mark it used, let the user set a new password, end the other sessions, and email them that the password changed. I rate limit the endpoint, and I do not change the account until the token is proven.

Why is SMS a weak second factor, and what is better? (Trap)

Phone numbers can be taken over by SIM swapping and the codes can be phished or intercepted. OWASP lists SMS as a restricted option. TOTP apps are better, and passkeys or security keys are the best because they are phishing resistant: the key answers only for the real website. Whatever I choose, I need a safe recovery path, because a weak reset flow becomes the new weakest link.

A login endpoint is being attacked with leaked passwords from other sites. What do you do? (Senior signal)

That is credential stuffing. I add rate limits by account and by IP, with delays rather than only hard locks. I block known breached passwords at sign-up and password change. I offer or require MFA. I use the same error message for every failure, and I log and alert on spikes of failures. I may add bot detection or a CAPTCHA as a helper. I also tell users whose passwords match a known breach to reset.

Is bcrypt still OK? (Trap)

Yes for existing systems, with a work factor of 10 or more, and I know its 72 byte password limit. OWASP lists it for legacy use and prefers Argon2id for new systems. I would not migrate in a panic. I can upgrade gently: verify with the old hash, and on a successful login re-hash with the new algorithm and save it.

Key takeaways

  • Never store passwords. Store a slow, salted hash: Argon2id first, scrypt if not available (set maxmem!), bcrypt for legacy, PBKDF2 for FIPS.
  • Fast hashes (MD5, SHA-256) get cracked quickly. Same password with no salt gives the same hash.
  • Use long passwords, no composition rules, block breached ones, don't force rotation. Give identical error messages.
  • Session IDs are long random values from randomBytes. Use HttpOnly, Secure, SameSite, the __Host- prefix, timeouts and a new ID at login.
  • A JWT payload is readable. Pin the algorithm, require exp, use a strong secret, keep secrets out of it.
  • Reset tokens are random, single use, short lived and stored only as a hash.
  • Offer MFA: TOTP is simple, passkeys are best. Make the recovery path strong too.
  • Rate limit by account and IP, and log lockouts.

What's next

Lesson 4 moves from "who are you?" to "what may you do?". It shows the number one web risk, broken access control, with a real IDOR bug in a tiny server and the fix. After that, Lesson 5 covers secrets, encryption and personal data. Practise now: take the login of a small project of your own and check three things: how passwords are stored, what flags the session cookie has, and whether the login is rate limited.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series