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.
On this page
- Words used in this lesson
- Why you never store a password
- Fast hashes are not enough
- The fix: a slow hash with a random salt
- Password rules that help (and rules that don't)
- Do not say which half was wrong
- Sessions and cookie flags
- JWT: what it is and how it goes wrong
- Password reset flow
- MFA: a second proof
- Slow down guessing: rate limits and lockout
- Try it
- Interview questions
- Key takeaways
- 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:
| password | |
|---|---|
| alice@example.com | sunshine |
| bob@example.com | letmein |
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:
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:
{ 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 msRead this slowly, because it shows three separate problems:
- 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.
- Same password, same hash. Look at
aliceanddave. Both chosesunshine, 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. - Precomputed tables. Because the hash of
sunshineis alwaysa941..., 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:
| Function | Minimum setting from OWASP |
|---|---|
| Argon2id | 19 MiB memory, 2 iterations, 1 parallelism (other equal trade-offs are listed, such as 46 MiB with 1 iteration) |
| scrypt | cost N = 2^17 (about 128 MiB), block size r = 8, parallelism p = 1 (other equal trade-offs are listed) |
| bcrypt | work factor 10 or more, and a 72 byte password limit |
| PBKDF2 | 600,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:
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:
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: falseWhat to notice:
- Same password, different stored strings. The two salts differ, so the two hashes differ. That fixes the
aliceanddaveproblem. - 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).
timingSafeEqualcompares 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:
timingSafeEqual length mismatch: ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTHThe memory trap. Node limits scrypt memory by default. With the OWASP setting and no maxmem, I got an error, not a hash:
default maxmem: ERR_CRYPTO_INVALID_SCRYPT_PARAMS - Invalid scrypt params: error:030000AC:digital envelope routines::memory limit exceededSo 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:
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.
Sessions and cookie flags
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:
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:
200 [ '__Host-sid=yBF-YYgPP2ncifjZ5Otazy0JOJzwaK5mqQwV1K_SylM; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600']| Part | What it does |
|---|---|
HttpOnly | JavaScript 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. |
Secure | The browser sends the cookie only over HTTPS, never over plain HTTP. |
SameSite=Lax | The 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- prefix | The 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-Age | A 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:
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:
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 signatureGo 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.
badDecodeis the classic bug: code that decodes the payload and trusts it. I changedroletoadminand kept the old signature, and the careless code acceptedadmin. The realverifysaidbad signature.alg: none. The JWT header says which algorithm was used. The spec has a valuenonemeaning "unsigned". An attacker setsalgtonone, 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. Myverifyrejected it.- No
exp(expiry). A token without an expiry lives forever. If it leaks, it works forever. Always set a shortexp(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
secretordev-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:
- Random from a cryptographically secure generator.
- Long enough that guessing is hopeless.
- Stored safely. I store only a hash of it.
- Single use, and it expires soon.
- Don't change the account until the token is shown (so an attacker cannot lock you out by asking for resets).
- Give the same message whether the email exists or not.
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:
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
Hostheader (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.
| Method | Good | Weak spot |
|---|---|---|
| TOTP app (6-digit code) | Cheap, works offline, widely supported | A fake site can ask for the code and use it within 30 seconds (phishing) |
| SMS or phone call codes | Easy for users | OWASP marks these as restricted; phone numbers can be hijacked |
| Passkeys / FIDO2 / security keys | Resistant to phishing, because the key only answers for the real site | Needs device support and a recovery plan |
| Recovery codes | A safety net when a phone is lost | Must 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.
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:
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.
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:
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 againNotice 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
- 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.
- Change scrypt to
N: 2 ** 14and measure the time. Then tryN: 2 ** 18withmaxmemlarge enough. What do you see? - Hash the same password twice with
hashPassword. Prove the stored strings differ and both verify. - Edit the JWT payload by hand (change
role) without re-signing. Make sureverifyrejects it. - Build a token with
alg: "none"and confirm yourverifyrejects it. - Extend the reset code so that a new reset request invalidates earlier unused tokens for the same user.
- Change the limiter to double the lockout time after each repeat block.
Answers
- 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.
- Lower N is faster (and weaker). A higher N is slower and needs more memory; if
maxmemis too small Node throws the memory limit error from this lesson. - The salts differ, so the strings differ. Both verify with
verifyPasswordbecause each string carries its own salt and settings. - The signature no longer matches the edited payload, so
verifythrowsbad signature. verifythrowsalg not allowed: nonebecause it checks the algorithm in code before it looks at the signature.- Keep a
userId -> tokenHashmap. When you create a new token, mark the previous rowused = true(or delete it). - Store a
strikescount per key. Set the window tobaseMs * 2 ** strikeswhen 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. UseHttpOnly,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
- Sessions vs JWT: Stateful vs Stateless Login · The Browser Contract
- Authentication and Authorization in Next.js · Next.js App Router
- API Authentication and Authorization: API Keys, OAuth 2.0 and Scopes · API Design
Part of the study guide
- Web Security Guide for Developers: OWASP Top 10 Basics
- Next.js Guide: App Router, Server Components and Deploying
Related series