Skip to contentExploitQuest

Lesson 1 of 1 in Authorization at the Boundary

One Place To Decide

If the answer to "may this person do this" is spread across your templates, it is not an answer.

3 min read

Not yet reviewed

Broken access control is the most common serious bug in web applications, and it is almost never a failure of the check. It is a failure to have one place where the check lives, so some paths reach the data without passing it.

A hidden button is not an access control

Decided in the interface

{user.isAdmin && <DeleteButton id={post.id} />}
// and the endpoint just deletes

Decided at the boundary

export async function deletePost(actor, id) {
  if (!can(actor.role, 'post.delete')) return err(forbidden())
  ...
}

The first hides the control and leaves the door open. Anybody who can type a URL — or read your JavaScript bundle, which is everybody — can call the endpoint directly.

This is worth stating precisely, because the mistake is subtle: the interface check is not wrong. Hiding a button a user cannot use is good design. It is only wrong when it is the only check, and it is the only check surprisingly often, because it is the one you can see working.

Where the boundary is

The boundary is the point where a request becomes an operation on data — the module's public function, not the route handler and not the component. Putting it there means every caller is covered, including the ones that do not exist yet.

HTTP route            -> parses input, has no opinion on permission
Module public API     -> decides. Every path passes here.
Module internals      -> assume the decision was made
Interface             -> hides what would be refused, as a courtesy
  1. Line 2One place. A new route, a background job, a CLI script and an import all reach the same function, so none of them can forget.
  2. Line 4A courtesy, and never the control. If this is the only line doing the work, the feature is unprotected.

The check that must not be a lookup of your own output

The second common shape is authorising against something the client sent. An endpoint that trusts a role field in the request body, or a userId in a hidden form input, has moved the decision back to the attacker.

Take care

Authorise against the session, always. The only trustworthy statement about who is asking comes from the server's own session lookup, and any identifier in the request is a claim rather than a fact.

How this platform enforces it

Authorization decisions live in module public APIs, and the module boundary itself is a build-time check: a module can only be reached through its index.ts, so there is no path that skips the front door and calls an internal function directly.

Note

That is the useful generalisation. The rule "authorise at the boundary" only holds if the boundary cannot be bypassed — so the architecture check and the authorization rule are the same rule, enforced twice.

An endpoint reads `req.body.userId` to decide whose invoices to return, and the interface only ever sends the logged-in user's id. Is it safe?