Where the Check Belongs
A hidden button is not a control. Middleware, handler, service and query each enforce something different — and only one of them is a guarantee.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
At which layer should an authorization check run, and what does each layer actually guarantee?
The team ships a "Delete workspace" button that only owners can see. Support asks whether a member could delete a workspace anyway. Nobody is sure.
The frontend already knows the user's role — it hides the button for non-owners. Adding the same rule in the backend duplicates logic in two languages and they will drift, so put the rule where the UI needs it and keep the API simple.
The API is reachable without the frontend. curl -X DELETE /workspaces/42 never renders a button, so a rule enforced in rendering does not run.
- The API is reachable without the frontend.
curl -X DELETE /workspaces/42never renders a button, so a rule enforced in rendering does not run. - Hiding is not even a client-side check: the data used to decide what to hide is normally in the JSON the API already sent, and the endpoint is normally still routable.
- Mobile builds lag. A rule changed on the web is not enforced by the app someone installed last year, and old clients live for years.
- The frontend genuinely does need the rule — to render sensibly — so you end up with it in both places regardless. The real question is which copy is *authoritative*, not whether to duplicate.
What is actually happening
- Client-side checks are affordances: they make the right action easy and the wrong action invisible. They are a usability mechanism executing on a machine the user controls, and they can be removed with developer tools (The Trust Boundary).
- A gateway or proxy check sees the token, the route and the method. It can enforce coarse things — this token has the
workspaces:writescope, this route requires authentication — and cannot enforce anything about workspace 42, because it does not have the row. - Middleware in your process sees the principal and the route before the handler runs. It is the right place for authentication and for coarse route-level requirements, and the wrong place for object rules, because it would have to load the object to decide (Middleware Ordering Is a Correctness Decision).
- The service layer sees the principal, the action and the loaded resource. This is where object-level rules can actually be evaluated, and where every caller — HTTP, GraphQL, job, CLI — converges if you route them through it (The Service Layer).
- The query is the strongest enforcement available in most applications: a
WHEREclause that includes the principal's scope means unauthorized rows never enter your process (The Repository Layer). Some databases push this further with row-level security, which enforces it even for a query that forgot (Database Privileges and Blast Radius). - These compose. Defence in depth here means a cheap rejection early and an authoritative one late, not one check chosen from a list (Defence in Depth).
What each layer can and cannot know
The reason object-level rules cannot live at the edge is not policy, it is information. The gateway has a token and a URL. Middleware has a principal and a route. Only code that has loaded the row knows who owns it, what state it is in and how much it is worth.
- Client — usability. Zero security value. Still worth doing.
- Gateway — authentication, scopes, route allow-lists. No knowledge of objects.
- Middleware — principal, route, coarse role gates. Cheap rejection.
- Service — the first layer that can evaluate a real rule.
- Query — the strongest ordinary enforcement: the row never arrives.
- Database policy — a backstop for code that forgot. Engine-dependent.
Choosing the authoritative layer
There is no universally correct answer, but there is a wrong one: leaving it undecided, so each engineer assumes a different layer is responsible. Pick based on how many ways your data can be reached and how much your rules depend on object state.
How many independent paths reach this data, and how much does the rule depend on the row?
when One transport, rules that depend only on role and route — an internal admin tool where every endpoint is "staff only".
cost Silently stops protecting the moment a second transport or a job reaches the same data.
when Several transports converge on one service module; rules involve object state ("only while draft", "not your own").
cost A caller that skips the service — a raw query, a migration script — skips the rule.
when Ownership or tenancy is a column, and every read should be filtered by it. The default for multi-tenant products (Tenant Isolation).
cost The security property looks like an ordinary filter and can be refactored away without anyone noticing.
when You need a backstop against application bugs, on an engine that supports it, and can reliably set the current principal per connection.
cost Policy lives where app engineers do not look; pooled connections must set and reset session state correctly or it silently does nothing.
when Many services, shared relationship-based rules, and a need to answer "who can access X" centrally.
cost A network call in the hot path of every request, plus its own availability, latency and consistency problems.
Give the client the decision, not the rule
The frontend genuinely needs to know whether to show the button. The mistake is making it derive that from a copy of the rule; the fix is to send the server's own answer alongside the resource. One rule, evaluated once, in the place that is authoritative.
This also removes a whole class of support tickets — the UI can no longer offer an action that the server refuses — and it gives you a natural place to expose *why* an action is unavailable.
// client
const canDelete = user.role === 'owner' || user.role === 'admin'
{canDelete && <DeleteButton />}
// server
app.delete('/workspaces/:id', requireLogin, handler)// server
res.json({
...workspace,
_permissions: {
delete: policy.canDelete(principal, workspace).allowed,
invite: policy.canInvite(principal, workspace).allowed,
},
})
// client
{workspace._permissions.delete && <DeleteButton />}
// server, on the actual DELETE — same function, authoritative
const d = policy.canDelete(principal, workspace)
if (!d.allowed) return res.status(403).json({ reason: d.rule })The two copies of the rule in the first version drift the first time the rule changes, and the client copy is not enforcement in either version. The second evaluates one function in one place; the client renders a server decision, and the DELETE path re-evaluates the same function against the same object rather than trusting the flag it sent earlier.
How to build it
Most important first.
- Decide which layer is authoritative and write it down. Everything above it is optimization or user experience; everything below it is a backstop.
- Put coarse rules where they are cheap: authentication and route-level scope in middleware, so an unauthenticated request never touches a database connection (Authenticate First, or Rate-Limit First?).
- Put object rules where the object is: in the query or immediately around it. A check on a row that is already in memory is correct but weaker, because a future code path may load the row without it.
- Give the frontend the same rule as *data*, not as duplicated logic — return the permitted actions with the resource (
"canDelete": false) so the UI renders from the server's decision instead of re-deriving it. - For high-value or destructive actions, re-check inside the transaction that performs the write, so a revocation or a state change between check and commit cannot slip through (Where the Transaction Boundary Goes).
What can go wrong
- Enforcement in middleware only, then a new transport (GraphQL, gRPC, a queue consumer) that bypasses the pipeline (What Belongs in the Pipeline).
- Middleware ordering that runs the authorization check before the body has been parsed or the resource resolved, so it silently decides on
undefined(The Middleware Pipeline). - A row-level security policy in the database that is bypassed because the application connects as a superuser or a role that is exempt (Connection Pools reusing a session that never set the current user is the usual mechanism).
- A check in the repository that a caller works around by writing one raw query "just for this report" (Raw SQL in Application Code).
- The server returning
canDelete: truecomputed by a different rule than the one enforced on delete, so the UI offers an action that always fails.
- Check in the handler, act in the service: between them, the object's state or the caller's permission can change. Placing the check in the same statement as the write closes the window (Optimistic Concurrency).
- If the only check is client-side, an attacker gets the full API with no obstacle at all: the request is well-formed, authenticated and accepted. Anyone who can open developer tools has the same access as an owner.
- If the only check is at the gateway on route and scope, an attacker gets everything within that scope:
workspaces:writebecomes write access to *all* workspaces, because the gateway cannot distinguish theirs from yours. - If the only check is in one handler, an attacker gets whatever the *other* code paths to the same data allow — and the second path is usually the one written later, under time pressure, for an internal tool.
- If the check is in the query, an unauthorized row never enters process memory, so it cannot leak through a log line, an error message, a cache entry or a serialization bug (Not Leaking Your Internals).
- Returning
canDeleteflags to the client is safe; deciding *on the basis of them* when the request comes back is not. The flag round-trips through the attacker.
- "The button is hidden, so the action is protected." Hiding changes what is easy, not what is possible (Authorization in Backends).
- "Our API is internal / not documented / behind a VPN." Undocumented is not unreachable, and an internal network is a network, not an authorization system.
- "The gateway has an auth policy, so services do not need one." A gateway can authenticate and scope; it cannot know who owns row 42.
- "Row-level security means we can stop checking in application code." It means the database backstops you. Business rules richer than a row predicate still live in the service (Business Validation).
Operating it
- Instrument each layer separately and compare. If middleware denies 200 requests a day and the service layer denies zero, either the service layer is unreachable except through middleware — or it is not checking.
- Log which layer denied. "403 at gateway" and "403 at repository" are different problems: the first is usually a client bug, the second is usually an attempt.
- For row-level security, periodically assert from a test that a query run without the session variable set returns nothing. A silent misconfiguration turns the policy off entirely (Test Against the Real Database).
- Rejecting early saves real resources under attack or accidental load: an unauthenticated request rejected in middleware never takes a connection from the pool (Connection Pool Exhaustion).
- Query-level scoping usually costs nothing extra if the scoping column is part of an index you already need; it can cost a great deal if it is not (Composite Indexes and the Leftmost-Prefix Rule).
- At many services, the enforcement point tends to drift toward the data owner: each service enforces on its own data, and callers pass identity rather than trust (Microservices).
- Query-level enforcement is the strongest and the least visible in code review — the security property is one
WHEREclause that looks like an ordinary filter and can be deleted while "fixing" a query. - Row-level security in the database is enforced even for code that forgot, and moves policy into a place most application engineers do not read, do not test locally and cannot easily debug (Database Privileges and Blast Radius).
- Checking at several layers means several places to update when a rule changes, and a genuine risk that they disagree. The mitigation — one shared policy function called from several layers — costs indirection.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe layering argument holds anywhere. What differs is which layers exist: a service with no gateway simply has one fewer coarse checkpoint.
- DATABASE-SPECIFICRow-level security is a Postgres and SQL Server feature (and Oracle VPD); MySQL has no equivalent and people emulate it with views, which are bypassable by any query that does not use them. Whether "the database can backstop you" is even available depends on the engine.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Frontend engineering — rendering from server-provided capability flags is a UI architecture decision as much as a security one.