Every mechanism in this course establishes who somebody is, and none of them says what that person may do.
The previous nine lessons built a trustworthy answer to the first question. This one is about the second, and then about fitting all of it together into a working system. If you have arrived here directly, the only assumption is that a request arrives carrying a session that reliably identifies a user.
Identity is not permission
A resolved session gives you a user ID. That is the whole of it. It says nothing about whether this user may read invoice 4192, edit the team's billing settings, or export the customer list.
The failure follows from a habit rather than from ignorance. A handler is written for the case in the designer's head, which is the user looking at their own thing, and the identifier is taken from the URL because that is where it is:
// The bug, in its purest form.
app.get("/invoices/:id", async (request) => {
const user = await requireSession(request); // authentication: done
const invoice = await db.invoice(request.params.id); // authorization: never happened
return Response.json(invoice);
});requireSession did its job perfectly. The request is authenticated. And any signed-in user can read every invoice in the system by changing a number in the URL.
This is insecure direct object reference, IDOR, and in the OWASP Top Ten it sits under broken access control, which has been the most common category in real applications for years. It is not exotic, it does not require tooling, and it is found by changing a digit.
The rule that prevents it is one line long: every request that touches a specific object must establish that this user may touch this object, on the server, from data the user did not supply.
Ownership, which is where most applications should stop
The simplest model, and the correct one for a large proportion of applications, asks a single question: does this row belong to the caller?
const invoice = await db.invoiceOwnedBy(request.params.id, user.id);
if (!invoice) return new Response("Not found", { status: 404 });Note what changed. The ownership condition moved into the query, so there is no window in which the wrong row exists in a variable, and no chance of a later refactor dropping the check while keeping the fetch. Writing it as a fetch followed by if (invoice.userId !== user.id) also works and is more fragile, because the safe version depends on a line that can be deleted without the code failing any test that uses the owner's own data.
Note also the 404. Returning 403 on a resource that exists tells the caller it exists, which is enumeration again: an attacker walks the ID space and learns which invoices are real and roughly how many customers you have. Answer 404 for both "no such thing" and "not yours" unless the distinction is genuinely useful to a legitimate user.
Most applications never need more than this. A note-taking app, a personal finance tracker, a blog with one author: the only question ever asked is whether the row belongs to the caller. Adding roles to such a system is a cost with no benefit, and the discipline of stopping here is worth more than the flexibility of a model nobody uses.
Ownership stops being enough at a specific and recognisable moment: the first time an object has more than one legitimate accessor. A shared document, a team invoice, an administrator who must read anything to answer a support ticket. At that point the question is no longer "is it yours" but "what is your relationship to it", and you need a model.
Example. A handler reads /api/orders/:id and checks if (order.customerId !== user.id) return 403. An attacker signed in as an ordinary customer iterates IDs from 1 to 10,000. What do they learn, and how do you fix the leak without changing the model?
They learn exactly which order IDs exist, because a real order belonging to somebody else returns 403 while an absent one returns 404, and the two are distinguishable in one request each. From ten thousand requests they recover the order count, the rate of orders over time from the density of live IDs, and a target list for any other bug. The fix is to return 404 in both cases and to move the ownership condition into the query so that "not found" and "not yours" are literally the same code path. The model is unchanged; only the shape of the answer is.
Now you. A team responds to that by switching order IDs to random UUIDs, arguing that nobody can guess them so the check is unnecessary. What is wrong?
Answer
A UUIDv4 has 122 random bits and is genuinely unguessable, so blind enumeration stops. That is worth having and it is not authorization. The identifier appears in URLs, so it reaches browser history, server access logs, analytics, Referer headers on outbound links, support tickets and any message where a user pastes a link, and every one of those is a path by which a valid identifier reaches somebody who should not have it. Once it does, there is no check to stop them. Unguessable identifiers reduce the rate at which references leak; the ownership check is what makes a leaked reference harmless. Do both, and never let the first substitute for the second.
Roles, and the day somebody needs most of one
The usual second model gives each user a role, and the code asks which role they hold.
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });It is easy to write and easy to read, and for a small number of clearly separated jobs it is the right amount of structure. admin, member, viewer covers a great many products honestly.
Roles start to hurt at a moment every growing team recognises. Somebody needs most of a role. The support team must read invoices to answer questions but must not issue refunds. The content team must publish but must not manage users. Each such request has three possible answers and all of them are bad. Grant the whole role and you have given away more than intended. Create support_readonly and the role list starts growing, and it grows combinatorially: with five separable capabilities, covering every useful combination needs up to roles. Or add a special case in the code, if (user.role === "admin" || (user.role === "support" && action === "read")), which is where the checks stop being auditable, because the policy now lives scattered through the handlers rather than in one place.
The diagnostic is simple. If you can list your roles on one hand and no one has asked for a variation in six months, roles are fine. If your role list has entries like admin_readonly or manager_no_billing, the model has already broken and the code is carrying the difference.
Permissions, and why retrofitting is the expensive part
The third model makes the individual right the unit. A user holds permissions such as invoice:read, invoice:refund, user:manage, and roles become names for bundles of them.
if (!user.permissions.has("invoice:refund")) return new Response("Forbidden", { status: 403 });The support case is now a bundle: invoice:read without invoice:refund, granted by a role called support, with no new concept and no special case in the handler. Twelve resource types with four actions each is 48 permissions, and any of the many bundles anyone asks for is a row in a join table rather than a new role in the code.
Here is the part that decides the design, and it is why this section exists rather than a shrug about tradeoffs. The two models cost about the same to build and wildly different amounts to change into each other.
Starting with permissions costs a permissions table, a role-to-permission join, and a check that reads a set instead of a string. That is perhaps a day's work at the outset, and the code at each call site is the same length.
Migrating roles to permissions later costs you every call site. Each user.role !== "admin" has to be replaced by the specific right that comparison was standing in for, and working out which right that was means reading the handler and deciding, one at a time, across a codebase where the answer is sometimes "several" and sometimes "nobody knows". Every special case accumulated in the meantime has to be untangled. And it cannot be done incrementally with confidence, because a mistake in either direction is invisible: too permissive is a silent hole, and too restrictive breaks a workflow for one customer next quarter.
So the recommendation is asymmetric on purpose. If the application will plausibly ever have more than three kinds of user, start with permissions and define roles as bundles from day one. If it genuinely will not, use ownership and stop. The middle option, roles as strings compared in handlers, is the one that is cheap now and expensive exactly when you are busiest.
One refinement worth naming: in a product with teams or organisations, permissions are almost never global. The unit is a permission within a scope, so the question is not "may Ada refund invoices" but "may Ada refund invoices in organisation 12". Build the scope in from the start, because adding it later is the same migration problem again, one level deeper.
Example. An application has roles admin, editor and viewer. Product asks for a support role that can read everything and change nothing, and a billing role that can do everything with invoices and nothing else. What happens under each model?
Under roles, you add support and billing, making five, and each new handler must now enumerate which of five roles may reach it. The list grows with every request of this kind, and the checks in the handlers get longer. Under permissions, nothing structural happens: support is a bundle containing every :read permission, and billing is a bundle containing the four invoice:* permissions. The handlers are untouched, because they were already asking for a specific right. The difference is not that permissions handle this request better; it is that permissions handle the next twenty without a code change.
Now you. The team has 40 handlers, each checking user.role. Estimate honestly what a migration to permissions involves, and say what you would do first.
Answer
Forty decisions, each requiring somebody to read the handler and name the right that the role comparison was standing in for, plus every accumulated special case, plus a data migration mapping existing users to bundles, plus a period where both systems run and can disagree. The dangerous part is not the volume but that errors are silent in both directions. What to do first is not to migrate: it is to stop the growth. Introduce the permission check as the only way new handlers are written, express the existing roles as bundles so both models describe the same thing, and convert old handlers when they are touched for other reasons. That way the count of role comparisons falls monotonically and no single change is large enough to be risky.
Where the check belongs
Three answers, and they are not equivalent.
In the handler is where most applications put it, and it works so long as every handler remembers. The weakness is structural: a check that must be repeated is a check that will eventually be omitted, and the omission is invisible in review because the code that should be there simply is not.
In middleware, matched by route pattern, catches whole groups at once and is excellent for coarse decisions such as "everything under /admin requires the admin:access permission". It cannot make the fine ones, because middleware runs before the object is loaded and therefore cannot know who owns it.
In the data layer is the most reliable, and it is the pattern behind row-level security in a database and behind repository methods that take the caller as an argument. If the only way to fetch an invoice is invoices.forUser(user).byId(id), a handler that forgets the check cannot compile a query that returns somebody else's row. The check has moved from something you must remember to something you cannot avoid.
The arrangement that works in practice uses all three: middleware for coarse gates, the data layer for ownership and scope, and handlers for the specific business rules that neither can express, such as "a refund over 1,000 needs the invoice:refund_large permission".
Two rules cut across all of it. Deny by default, so a route with no policy is refused rather than allowed, which converts a forgotten check from a hole into a visible failure. And never trust the client, which includes hidden form fields, disabled buttons, a role value in a JWT the client could have obtained under different circumstances, and the absence of a link in your own interface. Hiding an action in the interface is a usability decision. The server decides.
Example. Place each of these three checks at the right layer, and say why the other layers cannot do it. First, everything under /admin requires the admin:access permission. Second, a user may read only invoices belonging to their own organisation. Third, a refund above 1,000 requires invoice:refund_large.
The first belongs in middleware, matched on the route prefix. It needs nothing but the session and the path, so it can run before anything is loaded, and putting it there means a new route under /admin is covered on the day it is added rather than on the day somebody remembers. The second belongs in the data layer, because it depends on the object, and middleware runs too early to know which organisation the invoice is in. Expressed as invoices.forOrg(user.orgId).byId(id), a handler that forgets it cannot express the unsafe query at all. The third belongs in the handler, because it depends on the request body: no route pattern and no query constraint can see that the amount is 1,001 rather than 999.
Now you. A team puts all three checks in middleware, loading the invoice there and attaching it to the request. What have they gained and what have they broken?
Answer
They gained one place to read the policy, which is genuinely valuable. What they broke is the coupling: the middleware now has to know, for every route, which parameter names an invoice, which names an organisation, and what the body means, so it grows a switch over route patterns and becomes the scattered special cases the roles section warned about, only in one file instead of forty. It also loads objects for routes that do not need them and cannot express a rule that depends on two objects at once. Keep the coarse gate in middleware where it costs nothing, and push the object-dependent rules down to the layer that already has the object.
The whole flow, assembled
Every piece of the course now has a place. Here is the complete life of an account, with the lesson each step comes from.
Sign up. Accept the email and password. Check the password against a breach corpus with the k-anonymity protocol and refuse a known-compromised one, imposing no composition rules. Hash it with Argon2id at parameters measured on your own hardware. Create the account unverified, and respond identically whether or not the address was already registered, branching in the mail instead.
Verify. Mint 32 random bytes, store the SHA-256 of it with a one-hour expiry, and mail a link that lands on a page with a button, so a mail scanner cannot consume it. On the POST, consume the token and mark the address verified.
Sign in. Look up the user, verify against a dummy hash if there is none, and return one message for both failures. Apply per-account exponential backoff before the hash runs, and watch the global failed-to-successful ratio for stuffing. On success, if a second factor is enrolled, hold a short-lived single-use intermediate state and require a TOTP code with a one-step window and replay tracking, or a WebAuthn assertion verified against the stored public key with exact origin equality. Then rotate: generate 32 random bytes, store the SHA-256, and set __Host-session with HttpOnly, Secure, SameSite=Lax, Path=/ and an absolute expiry.
Stay signed in. Resolve the session by hashing the cookie value and reading the row. Refresh idle expiry, enforce absolute expiry, check the origin on every state-changing request, and check permissions against the database rather than against anything the browser sent.
Sign out, and sign out everywhere. Delete the row, or every row for the user, and expose the device list so a person can see what is live.
Recover. One live reset at a time, a hashed single-use token expiring in under an hour, an identical response for every address. On completion, change the password, consume the token, delete every session, notify the address on file, and send the user to the login page rather than signing them in.
Change email. Require the current password, confirm the new address before switching, and notify the old one with a way to object.
Notice what is load-bearing across all of it: the secret is high entropy and stored as a hash, the response is the same whichever branch ran, the credential is consumed on use, and any change of credential ends every session. Four ideas, applied seven times.
What actually goes wrong
The failures that recur, in the order they are usually found.
No authorization check. The single most common serious breach, and the reason the last lesson of a course about authentication is about something else.
The reset flow. Guessable tokens, tokens stored in the clear, tokens that outlive their use, resets that do not end other sessions.
Trusting the client. A role in a JWT, a hidden field, a price in a form body, an interface that hides a button and a server that does not check.
Wrong tool for the secret. A fast hash on a password, an encrypted value where a MAC was needed, encoding mistaken for encryption, === on a secret.
Cookie attributes. A missing HttpOnly, a Domain that hands the session to every subdomain, no absolute expiry.
Escaping at the wrong layer, or in the wrong context. Sanitising on input, escaping HTML into a script block, an unquoted attribute.
Rate limiting the wrong key. Per IP against an attacker with fifty thousand addresses, and a lockout that becomes the attacker's tool.
Recovery weaker than the thing it recovers. Recovery codes hashed with SHA-256, SMS that resets a hardware key, a mailbox that outranks a passkey.
Verification that stops at the signature. A JWT whose aud is unread, a WebAuthn response whose origin is matched by suffix, an ID token trusted because the userinfo call succeeded.
Every one of those is a specific check in a specific place, and every one of them has appeared in this course with the reasoning attached. That is the point of deriving rather than memorising: a checklist tells you what to do on the day you read it, and the reasoning tells you what to do when the situation is one nobody wrote down.
The last thing worth saying is that a system built this way is not finished, because none is. What it is, is a system where each decision was made deliberately, its cost is written down, and the person who arrives next can see why. That is what holding up actually means.