Web Foundations & Databases · beginner · ~12 min

Authentication, authorization, and account flows

- 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.

Overview

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.

Why it matters

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.

  • Broken access control routinely tops the OWASP Top Ten. A single missing check can let any logged-in user read or modify everyone else's data by changing a number in a URL.
  • Account-takeover bugs in reset and verification flows are catastrophic: a leaked reset token means the attacker becomes the victim, with no password-guessing required.
  • Username enumeration looks minor but is the reconnaissance step that makes brute-force, credential-stuffing, and phishing campaigns efficient.
  • MFA bypasses are high-severity findings precisely because MFA is the last line of defence when a password has already leaked.

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.

Core concepts

1. Authentication (authN) vs authorization (authZ)

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?

2. Broken access control

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.

3. Password-reset tokens

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.

4. Anti-enumeration

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.

5. MFA and its bypasses

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 /dashboard when ok is 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?

Syntax notes

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:

  • 401 vs 403. 401 means "we don't know who you are" (authentication failed). 403 means "we know who you are, and you may not" (authorization failed). Mixing them up is a symptom of conflating the two concepts.
  • The authorization check compares the resource's owner against the authenticated user — never against an ID taken from the request.
  • The order matters: authenticate first, load, then authorize before returning any data.

Lesson

Authentication and authorization sound alike and are constantly confused. Getting them straight is essential.

AuthN vs AuthZ

  • Authentication (authN)Who are you? Proving identity with a password, an MFA code, or a token.
  • Authorization (authZ)What may you do? Checking permissions for each action.

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.

Account flows and their pitfalls

  • Login — Must rate-limit and lock out to resist brute-force guessing. Use uniform error messages so attackers cannot enumerate valid usernames.
  • Password reset — The reset token must be unguessable, single-use, and expiring. Leaking it leads to account takeover. Common leaks include exposing it in a URL, sending it to the wrong user, or host-header poisoning (where an attacker forces the reset link to point at a domain they control).
  • Email verification — Confirms that a user owns an address. Weak verification lets attackers verify accounts they do not own.
  • MFA — A second factor on top of the password. Bypasses are high-impact findings. Examples include a step that can be skipped, codes that can be reused, or response tampering (changing the server's reply to fake a success).

The recurring bug

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.

Code examples

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.

Line by line

Walking the key example from top to bottom:

  1. The 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.
  2. The 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.
  3. authenticate(sessionCookie) is pure authentication: cookie in, user (or null) out. It answers only "who are you?" and deliberately says nothing about permissions.
  4. 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.
  5. 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.
  6. The demo loop runs Alice's session (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.

Common mistakes

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.

Debugging tips

Because this is a design topic, most "bugs" are logic and security flaws rather than crashes. Concrete ways to find them:

  • Change the ID and watch the response. Log in as one test user, then request another user's resource by editing the ID in the URL or request body. A 200 where you expected 403 is broken access control. This is the single most productive test.
  • Call the endpoint without the UI. Use 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.
  • Compare failure responses byte for byte. For login and reset, submit a valid vs invalid username and diff the message, the status code, and the response time. Any difference is an enumeration channel.
  • Reuse and delay tokens. Use a reset or MFA code twice; use it after its expiry. If either still works, single-use or expiry is missing.
  • Watch for 401 vs 403 confusion. If your app returns 200 for a forbidden action, or 401 when the user is logged in, the authentication/authorization boundary is muddled — a strong hint an authorization check is missing or misplaced.

Questions to ask when a flow "works but feels wrong":

  • Where exactly is the authorization check, and does it run on every path to this data (including alternate routes and API versions)?
  • Does the check compare the resource's owner to the authenticated user, or to an ID from the request?
  • Is any security decision being made in JavaScript that runs in the user's browser?
  • Could an attacker skip a step by navigating straight to a later URL?

Memory safety

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:

  • Validate on the server, always. Client-side checks (hidden buttons, disabled fields, JavaScript guards) are convenience, never security. Assume every request is hand-crafted by an attacker.
  • Fail closed. If an authorization check errors or a value is missing, deny access rather than allowing it. A null owner or an exception should end in 403, not a fall-through 200.
  • Least privilege. Give each session only the permissions it needs. A "pending MFA" or "unverified email" session should authorize as little as possible.
  • Use safe, tested primitives. For tokens use a cryptographically secure random generator and constant-time comparison; store password and token hashes, never the raw values. Do not invent your own crypto.
  • Treat every input as untrusted — including headers. The host-header-poisoning reset bug is a reminder that even the Host header an attacker controls can end up in a security-critical string.
  • Rate-limit sensitive endpoints (login, reset, MFA, signup) to blunt brute-force and enumeration, using the token-bucket approach from the related rate-limiter exercise.

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.

Real-world uses

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:

  • Write the authorization check as an explicit, named step in every handler that touches user-specific data — do not leave it implicit.
  • Return the right status: 401 for "not authenticated", 403 for "authenticated but not allowed", 404 where revealing existence would itself leak information.
  • Use one uniform failure message on login and reset; never confirm whether an account exists.
  • Make reset and verification tokens random, hashed at rest, single-use, and short-lived.

For advanced practitioners:

  • Centralise authorization in a policy layer or middleware so no endpoint can accidentally ship without a check; enforce it with tests that attempt cross-user access.
  • Adopt deny-by-default access control and add automated tests that assert a low-privilege user gets 403 on every protected route.
  • Normalise timing on authentication paths (always run a password hash) to close timing-based enumeration.
  • Model MFA as an explicit server-side session state machine (password-verified -> mfa-verified) rather than a screen, and rate-limit the code entry.
  • Log and alert on authorization failures — a spike in 403s across sequential IDs is an enumeration attempt in progress.

Practice tasks

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.

Summary

  • Authentication asks "who are you?"; authorization asks "what may you do?" They are different questions, checked at different times: authentication once, authorization on every sensitive request. Confusing them is the root of most account-flow bugs.
  • Broken access control — a missing per-action authorization check, often IDOR — is the number-one web risk. The fix is always a server-side check comparing the resource's owner (or a role) against the authenticated user; hiding UI or obscuring IDs is not protection.
  • Use the right status codes: 401 = not authenticated, 403 = authenticated but not permitted. Fail closed.
  • Reset and verification tokens must be unguessable, single-use, expiring, and account-bound; store their hashes, and build links from a trusted base URL to avoid host-header poisoning.
  • Defend against enumeration with uniform failure messages, matching status codes, normalised timing, and rate limiting on login, reset, verification, and signup.
  • Treat MFA as a server-enforced state, not a screen: a password-verified session must authorize nothing until the second factor is verified server-side; watch for skippable steps, code reuse, missing rate limits, and response tampering.
  • The one habit to remember: for every request, ask both questions — prove identity, then check permission — and make the second decision on the server.

Practice with these exercises