Authentication, sessions and CSRF
Store passwords with a slow hash, build a login that leaks nothing and limits guesses, expire and revoke sealed sessions, and refuse requests another site started.
What you'll learn
When you finish, you can do these.- 1Store passwords as a salted, slow hash
- 2Build a login that leaks nothing and limits guesses
- 3Make sessions expire and revoke stolen copies
- 4Stop CSRF: refuse requests another site started
The Corner Shop, the small online shop this track keeps safe, is a Nuxt app: Vue with its own server. Customers sign in at /api/login, an API route (a server address that answers with data) and the most attacked URL on the site. People try passwords leaked from other sites against it, ask it which emails have accounts, and reuse stolen sessions, the cookies that keep someone signed in. Other sites can even make a signed-in customer's browser send the shop requests.
The Code tab holds the shop's sign-in code, one small file per defence; by the end you know what each one stops. You need one idea from The OWASP Top 10, for Vue developers: anything a browser sends can be changed, even an order number, so the server checks who is asking.
Passwords: long, hashed, slow
Never store a password, so a leaked database gives none away. Store a hash: the output of a one-way function, from which the password cannot be read back. At sign-in, the server hashes what was typed and compares. Make the hash deliberately slow and salted (mixed with random bytes per user), so every guess against a leaked copy costs real time and memory.
OWASP, the security non-profit behind the Top 10, ranks argon2id first and scrypt second in its Password Storage Cheat Sheet. The shop uses scrypt, built into Node, which runs the server. Its settings, in server/utils/password.ts:
const params = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }
These are OWASP's minimum scrypt settings; maxmem lets Node use the 128 MiB they need. One hash takes a few hundred milliseconds: slow for a guesser, fine for a login.
New passwords follow NIST SP 800-63B-4, the US government's sign-in guidelines: at least 15 characters (8 when the password is one step of multi-factor sign-in, MFA), at least 64 allowed, no forced mixes of symbols and digits, and no forced changes. Check them against lists of breached passwords instead. NewPassword, a Zod schema (a rule for valid data) in shared/utils/passwordRule.ts, gives the sign-up form and the server one rule.
A login route that gives nothing away
The login route, server/api/login.post.ts, limits guesses, answers every failure the same way and starts a session that later requests check.
- POST /api/loginrate limits
- verifyPassworda dummy hash for unknown emails
- New sessionclear() then update()
- Every requestrequireUser()
First, two rate limits, caps on attempts; past a cap, the shop's rateLimit() answers 429 Too many attempts. A limit per connection address slows one machine; one per account holds even when a thousand machines guess one person's password.
Then verifyPassword() runs the slow hash even for an unknown email, against dummyHash(), a hash of a random password, so every failure takes as long. Both steps, in the route:
rateLimit(`login-ip:${getRequestIP(event) ?? 'unknown'}`, 100)
const { email, password } = await readValidatedBody(event, Login)
rateLimit(`login-account:${email}`, 10)
const account = await db.accounts.findByEmail(email)
const ok = await verifyPassword(account?.passwordHash ?? await dummyHash(), password)
if (!account || !ok) {
throw createError({ status: 401, statusText: 'Wrong email or password' })
}
db is the shop's database helper; Login is the schema for the posted fields. A wrong email and a wrong password get the same answer, 401 Wrong email or password, in the same time.
Then the route calls the session's clear() before update(), which writes the user in. The session gets a new id, so nothing from before sign-in carries over. From then on, requireUser() reads it on every private route.
Cookies, revocation and CSRF
After sign-in, the session cookie proves who someone is, so it needs protection, an end date, a way to cancel it and a guard against other sites. useSession(), the Nuxt helper that seals the session into an encrypted cookie, already marks it HttpOnly (page scripts cannot read it), Secure (HTTPS only) and SameSite=Lax (not sent with most requests other sites start). The shop's wrapper, useShopSession() in server/utils/session.ts, writes them out so nothing rests on a default.
Without maxAge, a lifetime in seconds, the cookie lasts until the browser closes, and the sealed value inside never expires, so a copy keeps working. The shop's maxAge is a week.
Logout has the same gap: session.clear() deletes this browser's cookie, but the server keeps no list of sessions, so a copy taken earlier still signs in. The fix is a number on the account, sessionVersion, copied into the session at sign-in as v. requireUser() compares the two and answers 401 Sign in again when they differ. "Sign out everywhere" (/api/logout-everywhere) and a password change add one to the account's sessionVersion.
Cross-site request forgery (CSRF) is another site making your visitor's browser send a request, with the cookie attached. SameSite=Lax stops most of it, with three gaps:
- A sibling subdomain (
forum.example.comnext toshop.example.com) or another port counts as the same site. - A link on another site opens the shop with a
GETrequest, cookie included, so aGETroute must never change data. - A plain HTML form on such a sibling site can post to a JSON route, because the body reader also parses form fields.
For the other two gaps, server/middleware/csrf.ts, a check that runs before every route, refuses any request other than GET, HEAD and OPTIONS unless its Sec-Fetch-Site header, a label current browsers add, says same-origin: started by this site's own pages.
Without Sec-Fetch-Site, the check reads Origin, the site that sent the request. Never check Origin alone: with Referrer-Policy: no-referrer, your own forms send Origin: null.
What goes wrong
Two mistakes look fine in a review but help an attacker. The first skips the slow hash when the email is unknown.
const account = await db.accounts.findByEmail(email)
const ok = account ? await verifyPassword(account.passwordHash, password) : false
if (!ok) throw createError({ status: 401, statusText: 'Wrong email or password' })
Both answers say Wrong email or password, but a real email takes one scrypt hash, about 280 ms on a laptop, and an unknown one under 2 ms. Anyone can time that and learn which emails have accounts. Hash against the dummy:
const ok = await verifyPassword(account?.passwordHash ?? await dummyHash(), password)
if (!account || !ok) throw createError({ status: 401, statusText: 'Wrong email or password' })
Both failures now take the same time.
The second reads the address for the rate limit from the X-Forwarded-For header, which proxies (servers that pass requests on) fill in. The sender can write that header too.
rateLimit(`login-ip:${getRequestIP(event, { xForwardedFor: true })}`, 10)
Send X-Forwarded-For: 10.0.0.1, then .2, then .3, and every attempt starts a fresh count: a hundred guesses, a hundred 401s, never a 429. Trust the header only behind a proxy you control that overwrites it, and add the per-account limit, which no header changes:
rateLimit(`login-ip:${getRequestIP(event) ?? 'unknown'}`, 100)
const { email, password } = await readValidatedBody(event, Login)
rateLimit(`login-account:${email}`, 10)
The eleventh try on one account gets 429, whatever the header says.
What to remember
- Store salted, slow hashes: argon2id or scrypt with OWASP's settings. New passwords need 15 characters, or 8 with MFA, and no forced symbols or digits.
- A login answers the same message in the same time for a wrong email and a wrong password, so verify against a dummy hash.
- Rate-limit per address and per account, and never take the address from a header the sender can write.
- A sealed session cookie stays valid until it expires. Set
maxAge, and revoke copies with a version number on the account. SameSite=Laxis not the whole CSRF answer. Refuse data-changing requests whoseSec-Fetch-Siteis notsame-origin.
In your project
Do these in a real project. Each one says how to confirm it worked.- Time ten failed logins for a real email and ten for an unknown one, for example with
curl -s -o /dev/null -w '%{time_total}\n'in a loop. With the dummy hash the two columns look the same. Swap in the version without it and the unknown email answers in a millisecond or two. - Send eleven failed logins for another email with
-H "X-Forwarded-For: 10.0.0.$i", changing the number each time. The first ten get 401 and the eleventh gets 429 Too many attempts. - Sign in, open DevTools, then Application, then Cookies:
nuxt-sessionshows HttpOnly, Secure, SameSite Lax and an expiry a week away. Copy the cookie into acurl -bcall to a private route: 200. Runawait fetch('/api/logout-everywhere', { method: 'POST' })in the DevTools console and repeat the curl: 401 Sign in again.