Web Foundations & Databases · beginner · ~12 min
- Explain the difference between **authentication** (who you are) and **authorization** (what you may do), and give an example of a user who is one but not the other. - Identify the characteristic weaknesses in each account flow: login, password reset, email verification, and multi-factor authentication (MFA). - Describe what makes a password-reset token safe: unguessable, single-use, and time-limited. - Recognise **broken access control** and explain why a server-side check is required on every sensitive action. - Design anti-enumeration defences (uniform responses, rate limiting) that stop attackers from learning which accounts exist. - Trace a login-and-access request end to end and point to the exact step where an authorization check belongs.
When you sign in to a website, two separate questions get answered — and beginners (and plenty of professionals) constantly blur them together.
The first question is authentication: are you really who you claim to be? You answer it by proving something — a password you know, a code from your phone, a token your browser holds.
The second question is authorization: now that we know who you are, are you allowed to do this particular thing? Being logged in does not mean you may open any page or edit any record.
Here is the key insight: a user can be perfectly authenticated and still not be authorized. You are logged in as yourself, but you have no right to read another customer's invoice. When a site checks the first question and forgets the second, you get broken access control — for years the number-one item on the OWASP Top Ten list of web risks.
This lesson builds directly on Cookies, sessions, and browser storage. There you learned how a server remembers who you are between requests: it hands the browser a session cookie, and the browser sends it back on every request. That mechanism is authentication carried across a stateless protocol. This lesson picks up where that leaves off: once the server knows who you are, how does it decide what you may do — and how do the flows that establish identity (logging in, resetting a password, verifying an email, entering an MFA code) go wrong?
Plain language first: authentication = proving identity; authorization = granting permission. Everything else in this lesson hangs off that one distinction.
Account and access flows are where the most damaging web vulnerabilities live, because a flaw here does not leak one field — it hands over a whole account or a whole dataset.
For anyone learning defensive security, these flows are the highest-value place to spend attention. And the good news is that most of the bugs reduce to one habit of mind — keeping authentication and authorization firmly apart — which makes them findable without deep tooling.
Definition. Authentication is establishing and verifying identity. Authorization is deciding what an established identity is permitted to do.
Plain language. Authentication is showing your ID card at the door. Authorization is the guest list that says which rooms your badge opens. Getting through the door does not mean every door is yours.
How it works internally. Authentication usually happens once: you submit credentials, the server verifies them and issues a session (a cookie tied to server-side state) or a token. On every subsequent request the server should re-derive your identity from that cookie/token and then run an authorization check: does user 42 have permission to touch resource X? Authentication is a one-time event; authorization is a per-request decision.
Browser Server
| POST /login (user+pass) |
|----------------------------->| authN: verify credentials (ONCE)
| Set-Cookie: session=abc123 |
|<-----------------------------|
| |
| GET /invoice/998 |
| Cookie: session=abc123 |
|----------------------------->| authN: session -> user 42 (every request)
| | authZ: does user 42 own invoice 998? <-- must not skip
| 200 OK or 403 Forbidden |
|<-----------------------------|
When to keep them separate / pitfall. Always. The classic pitfall is "I checked they were logged in, so they're allowed." Being logged in answers authentication only. The authorization question — this user, this resource, this action — still has to be asked.
Knowledge check. A page runs
if (session.isLoggedIn) { showInvoice(request.params.id); }. Which of the two questions does it answer, and which does it skip?
Definition. A missing or incorrect authorization check that lets a user perform an action or reach data outside their permission.
Plain language. The app trusts what the request asks for instead of checking what the requester is allowed to have. The most common form is IDOR — Insecure Direct Object Reference — where an ID in the URL or body points straight at a record and the server never checks ownership.
How it works internally. GET /invoice/998 reaches a handler that loads invoice 998 and returns it — using the ID from the request as the sole input. Because the handler never compares invoice 998's owner to the current session's user, any logged-in user can walk the ID space: 997, 999, 1000.
Attacker (logged in as user 42) requests other people's invoices:
GET /invoice/997 ---> 200 OK (belongs to user 17!) <-- leak
GET /invoice/998 ---> 200 OK (attacker's own)
GET /invoice/999 ---> 200 OK (belongs to user 88!) <-- leak
Correct behaviour:
GET /invoice/997 ---> 403 Forbidden (not user 42's)
When it applies / pitfall. Any endpoint that references a specific record needs an ownership or role check. The pitfall is "security by obscurity" — assuming nobody will guess or change the ID. IDs are visible and trivially editable; hiding a button in the UI does nothing, because the attacker calls the endpoint directly.
Knowledge check (find the bug). An admin panel is served at
/admin/dashboard. The frontend only shows the "Admin" link to admins, but the server returns the dashboard to anyone who requests that URL. Name the vulnerability class and the missing check.
Definition. A short-lived secret the server emails to a user so they can set a new password without knowing the old one.
Plain language. Because the whole point of reset is that the user forgot their password, the emailed link is temporary proof of identity. If an attacker gets that proof, they own the account — no password needed. So the token must be hard to guess, usable only once, and expire quickly.
| Property | Why it matters | What goes wrong without it |
|---|---|---|
| Unguessable | Stops attackers from forging or brute-forcing tokens | Sequential or short tokens can be guessed |
| Single-use | Prevents replay after the password is changed | An old link keeps working |
| Expiring | Limits the window if an email is later exposed | A months-old link still resets the account |
| Bound to the account | Ties the token to one user | A token issued for A resets B |
How it works internally. On "forgot password", the server generates a cryptographically random token, stores a hash of it with the user ID and an expiry timestamp, and emails a link containing the token. On submit, the server hashes the supplied token, looks it up, checks it is unexpired and unused, resets the password, and then deletes/marks the token used.
Common leaks / pitfall. Putting the token in a URL that gets logged or sent to a third-party analytics script; emailing it to the wrong address; or host-header poisoning, where the app builds the reset link from the incoming Host header, so an attacker who controls that header makes the victim's link point at the attacker's domain — and the token lands in the attacker's logs.
Knowledge check (explain in your own words). Why is "single-use" a separate requirement from "expiring"? Give a scenario an expiry alone would not stop.
Definition. Designing responses so an attacker cannot tell, from the outside, which usernames or emails are registered.
Plain language. If "unknown email" and "wrong password" produce visibly different results, an attacker can probe a list of emails and learn which ones have accounts — then focus password guessing and phishing on real users.
How it works internally. Enumeration leaks through three channels: the message ("no such user" vs "wrong password"), the status code, and the timing (a real account runs an expensive password hash; a missing one returns instantly). Defences: return a single uniform message for every login failure, keep status codes identical, and normalise timing (e.g. always run a hash, even against a dummy value). For "forgot password", always say "if that address exists, we've sent a link" regardless of whether it does.
When to apply / pitfall. On every endpoint that takes a username or email: login, reset, verification, signup. The subtle pitfall is signup: "that email is already registered" is itself an enumeration oracle. Combine uniform responses with rate limiting — the same token-bucket smoothing behind API throttling — so even a probe that leaks slightly cannot be run millions of times.
Definition. Multi-factor authentication requires a second, independent proof (a phone code, an authenticator app, a hardware key) in addition to the password.
Plain language. MFA exists so that a stolen password alone is not enough. A bypass re-enables exactly the disaster MFA was meant to prevent, which is why bypasses are treated as critical.
How it works / common bypass shapes. After the password step, the server should place the session in a "pending MFA" state that grants no privileges until the second factor is verified server-side.
| Bypass shape | What the attacker does | The fix |
|---|---|---|
| Skippable step | Jumps straight to a post-login URL, skipping the MFA page | Enforce state server-side; a pending session authorizes nothing |
| Code reuse | Replays a previously valid code | Invalidate each code after one use; short expiry |
| No rate limit | Brute-forces the 6-digit code | Lock/throttle after a few attempts |
| Response tampering | Edits the server's {"mfa":"fail"} reply to "pass" in the browser |
Never trust client-side results; decide on the server |
Pitfall. Treating MFA as a UI screen rather than a server-enforced state. If the server still considers you fully logged in the moment your password checks out, the MFA page is decoration and any of the bypasses above works.
Knowledge check (predict the outcome). The MFA page calls
/verify-code, which returns{"ok": true/false}, and the browser redirects to/dashboardwhenokis true. An attacker intercepts a false response and flips it to true. Does this grant access? What server-side design would make the flip useless?
This is a conceptual lesson, so the "syntax" is the shape of a correct authorization check rather than a language feature. The pattern below (pseudocode) is the one to memorise: derive identity from the session, then check permission for this resource before returning it.
function handleGetInvoice(request):
# authN: turn the session cookie into a known user (or reject)
user = authenticate(request.cookies["session"])
if user is None:
return 401 Unauthorized # not logged in at all
invoice = db.loadInvoice(request.params.id)
if invoice is None:
return 404 Not Found
# authZ: the check that is so often missing
if invoice.ownerId != user.id and not user.isAdmin:
return 403 Forbidden # logged in, but not allowed
return 200, invoice
Key structural points:
Authentication and authorization sound alike and are constantly confused. Getting them straight is essential.
A user can be authenticated but still not authorized for a given resource. For example, you may be logged in (authenticated) yet have no right to open another user's invoice (not authorized).
Broken access control — missing authorization checks — is consistently the number-one web risk.
Most account-flow vulnerabilities come down to one pattern: missing or per-step-only authorization.
The app checks who you are at login but never checks whether this specific request is allowed.
The fix: every sensitive action needs its own server-side authorization check.
Below is a small, runnable simulation you can execute with Node.js (node auth_demo.js). It has no web server and no real database — it is an in-memory model that shows the exact difference between a vulnerable handler that only authenticates and a fixed handler that also authorizes. Reading the two side by side is the whole point.
"use strict";
// --- tiny in-memory "database" -------------------------------------------
const users = {
"sess-alice": { id: 42, name: "Alice", isAdmin: false },
"sess-bob": { id: 17, name: "Bob", isAdmin: false },
};
const invoices = {
997: { id: 997, ownerId: 17, amount: 120 }, // Bob's
998: { id: 998, ownerId: 42, amount: 55 }, // Alice's
999: { id: 999, ownerId: 88, amount: 900 }, // someone else's
};
// authN: turn a session cookie into a user, or null if unknown
function authenticate(sessionCookie) {
return users[sessionCookie] || null;
}
// --- VULNERABLE: authenticates but never authorizes ----------------------
function getInvoiceVulnerable(sessionCookie, invoiceId) {
const user = authenticate(sessionCookie);
if (!user) return { status: 401, body: "Not logged in" };
const invoice = invoices[invoiceId];
if (!invoice) return { status: 404, body: "No such invoice" };
// BUG: returns the invoice to ANY logged-in user (broken access control / IDOR)
return { status: 200, body: invoice };
}
// --- FIXED: authenticates AND authorizes ---------------------------------
function getInvoiceFixed(sessionCookie, invoiceId) {
const user = authenticate(sessionCookie);
if (!user) return { status: 401, body: "Not logged in" };
const invoice = invoices[invoiceId];
if (!invoice) return { status: 404, body: "No such invoice" };
// authZ: the missing check restored
if (invoice.ownerId !== user.id && !user.isAdmin) {
return { status: 403, body: "Forbidden" };
}
return { status: 200, body: invoice };
}
// --- demo: Alice (id 42) tries to read every invoice ---------------------
const ids = [997, 998, 999];
console.log("Alice's session probing invoices\n");
console.log("VULNERABLE handler:");
for (const id of ids) {
const r = getInvoiceVulnerable("sess-alice", id);
console.log(` GET /invoice/${id} -> ${r.status} ${JSON.stringify(r.body)}`);
}
console.log("\nFIXED handler:");
for (const id of ids) {
const r = getInvoiceFixed("sess-alice", id);
console.log(` GET /invoice/${id} -> ${r.status} ${JSON.stringify(r.body)}`);
}
What it does. Alice's session is authenticated in both versions. She then requests invoices 997, 998, and 999. Only 998 is hers.
Expected output:
Alice's session probing invoices
VULNERABLE handler:
GET /invoice/997 -> 200 {"id":997,"ownerId":17,"amount":120}
GET /invoice/998 -> 200 {"id":998,"ownerId":42,"amount":55}
GET /invoice/999 -> 200 {"id":999,"ownerId":88,"amount":900}
FIXED handler:
GET /invoice/997 -> 403 "Forbidden"
GET /invoice/998 -> 200 {"id":998,"ownerId":42,"amount":55}
GET /invoice/999 -> 403 "Forbidden"
Edge cases to notice. An unknown session yields 401 in both versions (authentication genuinely fails). A missing invoice yields 404. Only the authorization outcome differs: the vulnerable version leaks Bob's and the stranger's data with a 200; the fixed version returns 403 for anything Alice does not own. An admin user (isAdmin: true) would legitimately pass the check — showing that authorization is about roles and ownership, not a blanket allow/deny.
Walking the key example from top to bottom:
users map models server-side session state: a cookie string maps to a user record. This is exactly the session mechanism from Cookies, sessions, and browser storage — the cookie is opaque; the real identity lives on the server.invoices map is the protected data. Note that each invoice carries an ownerId. That field is what authorization will compare against — without an owner recorded, no ownership check is even possible.authenticate(sessionCookie) is pure authentication: cookie in, user (or null) out. It answers only "who are you?" and deliberately says nothing about permissions.getInvoiceVulnerable calls authenticate, rejects unknown sessions with 401, handles a missing invoice with 404 — and then returns the invoice. The comment marks the exact missing line: it never compares invoice.ownerId to user.id. Every logged-in user therefore reads every invoice.getInvoiceFixed is identical until the authorization guard: if (invoice.ownerId !== user.id && !user.isAdmin) return 403. That one condition is the entire fix. It runs after authentication and before the data is returned.id: 42) against invoices 997 (Bob's), 998 (hers), 999 (a stranger's).Trace of the fixed handler for Alice:
| Request | user.id | invoice.ownerId | ownerId === user.id? | isAdmin? | Result |
|---|---|---|---|---|---|
| /invoice/997 | 42 | 17 | no | no | 403 Forbidden |
| /invoice/998 | 42 | 42 | yes | — | 200 OK |
| /invoice/999 | 42 | 88 | no | no | 403 Forbidden |
The result is produced because the guard short-circuits: as soon as the owner does not match and the user is not an admin, the handler returns 403 without ever touching the data. In the vulnerable version that row of the table simply does not exist, so control falls through to the unconditional return 200.
Mistake 1 — "Logged in" treated as "allowed."
// WRONG: authentication used as if it were authorization
if (session.isLoggedIn) {
return db.loadInvoice(request.params.id);
}
Why it is wrong: it answers only who are you? and never may you have this? Any authenticated user reads any record. Corrected: load the record, then compare its owner to the session user (see the fixed handler above) and return 403 on mismatch. Prevent it by making "authenticated" and "authorized" two distinct checks in your mental checklist for every handler.
Mistake 2 — Hiding the button instead of guarding the endpoint.
WRONG: frontend hides the "Delete user" button for non-admins,
but DELETE /users/17 works for anyone who sends the request.
Why it is wrong: the browser UI is fully under the attacker's control; they call the API directly with curl. Corrected: enforce the role check on the server inside the DELETE /users/:id handler. Recognise it by asking "what happens if someone calls this endpoint without the UI?"
Mistake 3 — Reset token that is guessable or reusable.
WRONG: reset link = /reset?user=alice&token=1042 (sequential id as token)
Why it is wrong: an attacker increments the number and forges valid links; and if the token still works after use, an old email keeps resetting the account. Corrected: use a cryptographically random token, store only its hash with an expiry, mark it used after one reset. Prevent it by never deriving a secret from a predictable value.
Mistake 4 — Distinct error messages that leak account existence.
WRONG: "No account with that email" vs "Incorrect password"
Why it is wrong: the difference tells an attacker which emails are real. Corrected: one uniform message ("invalid email or password") for every failure, matching status codes, and normalised timing. Recognise it by trying a known-bad email and a known-good one and comparing every part of the response, including how long it took.
Mistake 5 — Trusting a client-side MFA result.
// WRONG: browser decides success
if (response.mfaPassed) window.location = "/dashboard";
Why it is wrong: the attacker edits the response in their browser. Corrected: keep the session in a "pending MFA" state that authorizes nothing; only a server-side verification of the code promotes it to fully authenticated. Prevent it by never letting the client be the authority on an auth decision.
Because this is a design topic, most "bugs" are logic and security flaws rather than crashes. Concrete ways to find them:
curl or your browser's network tab to replay a privileged request as a low-privilege user. If it succeeds, the protection was only in the frontend.Questions to ask when a flow "works but feels wrong":
This is a concept-track lesson with no C code, so there is no manual memory management to worry about. The equivalent discipline here is robustness and defensive design — the habits that keep an auth system from failing open:
null owner or an exception should end in 403, not a fall-through 200.Host header an attacker controls can end up in a security-critical string.Where a vulnerability appears in this lesson (the IDOR in the vulnerable handler, the client-trusting MFA check), it is clearly labelled and paired with the server-side fix. Keep exploration lab-only: test these ideas against your own applications, never against systems you do not own.
Concrete use cases. Every application with accounts implements these flows: banking and invoicing portals (the invoice-ownership check above is a real pattern), SaaS dashboards with per-tenant data isolation, e-commerce order history, healthcare portals under strict access rules, and internal admin tools. Broken access control in exactly these contexts has caused real, widely reported breaches where users read others' orders, messages, or documents by changing an ID. Password-reset and MFA flows are the front line of account-takeover defence for email providers, social networks, and cloud consoles.
Professional best practices.
For beginners:
For advanced practitioners:
password-verified -> mfa-verified) rather than a screen, and rate-limit the code entry.Beginner 1 — Classify the flows.
Objective: cement the authN/authZ distinction. Given these six situations, label each as an authentication failure, an authorization failure, or both, and justify in one sentence: (a) wrong password at login; (b) a logged-in standard user opens /admin; (c) an expired session cookie; (d) a user edits another user's profile by changing the URL id; (e) an MFA code is rejected; (f) an API key is missing entirely. Concepts: authN vs authZ, 401 vs 403. Hint: ask "do we know who they are?" first, then "are they allowed?"
Beginner 2 — Write the missing check in words.
Objective: describe a correct authorization guard. For an endpoint GET /orders/:id, write step-by-step (in plain English or pseudocode) the checks the server must perform, in order, and state the exact status code returned at each failure point. Requirements: cover not-logged-in, order-not-found, and not-the-owner cases. Concepts: order of checks, ownership comparison, status codes. Hint: model it on the syntax_notes pattern; the ownership line is the one people forget.
Intermediate 1 — Harden a password-reset design.
Objective: design a safe reset flow. Write the token lifecycle: how the token is generated, what is stored server-side, what the email contains, and what the server checks on submit. Requirements: address unguessable, single-use, expiring, and account-bound; and describe how you would prevent host-header poisoning of the reset link. Input/output: describe the "forgot password" response for both an existing and a non-existing email — they must be indistinguishable. Concepts: reset tokens, anti-enumeration. Hint: store a hash of the token, and build the link from a configured base URL, not the request Host.
Intermediate 2 — Extend the demo with roles.
Objective: modify the lesson's JavaScript simulation. Add an admin user and a role field to invoices' access rules so that (a) owners can read their own invoices, (b) admins can read any invoice, and (c) a new "support" role can read but never sees the amount field. Requirements: keep the vulnerable and fixed handlers side by side; print a table of who-can-see-what across all three roles and all three invoices. Constraints: no external libraries. Concepts: role-based authorization, least privilege, field-level access. Hint: authorization can be finer than allow/deny — it can control which fields are returned.
Challenge — Model an MFA state machine and attack it.
Objective: design a login-plus-MFA flow as an explicit server-side state machine, then reason about attacks. Requirements: define the states (anonymous, password-verified, mfa-verified), the transitions and what triggers each, and exactly which actions are authorized in each state. Then, for each of the four MFA bypass shapes in the lesson (skippable step, code reuse, no rate limit, response tampering), explain how your state machine defeats it — or, if it does not, add the rule that closes the gap. Deliver a text state diagram plus a short table mapping each bypass to its defence. Constraints: every security decision must be made in the password-verified -> mfa-verified transition on the server. Concepts: MFA bypasses, server-side state, least privilege, deny-by-default. Hint: the core invariant is that a password-verified session must authorize nothing sensitive until it becomes mfa-verified.