Hard lesson 06

Defense in Depth

A signed token proves who is calling. It says nothing whatsoever about what they may do. Almost every access-control gap in a service estate lives in that one sentence, and the most common way it hides is a beautifully built UI that renders exactly the right buttons for exactly the right people, in front of an API that would have accepted the call from anyone holding a valid token.

Level Intermediate Stack Java 17 · Spring Security 6 · Vue 3 Evidence Own estate, anonymised

The claim under test

This is the code that carries the belief. It is good code: it does its job well, and its job is a real one. The problem is not the snippet; it is the sentence engineers say out loud while writing it: "only admins can archive, because only admins see the button."

vuepresentation layer · the control is conditional
<template>
  <tr v-for="row in rows" :key="row.id">
    <td>{{ row.title }}</td>
    <td>
      <!-- Hidden when the client-side store says the user is not an admin. -->
      <button v-if="user.isAdmin" @click="archive(row)">Archive</button>
    </td>
  </tr>
</template>

Hiding the control prevents a mistake. It does not prevent an action. The button is one of several ways to reach the endpoint, and it is the only one the browser owns: the same call can be made from a terminal, a script, a second tab, or the application's own JavaScript with a flag flipped in a debugger. None of those paths pass through the v-if. All of them arrive at the service with a perfectly valid token.

So the question this page settles is not "should the UI filter permissions". It should, and there is a right way to do it that we will get to. The question is what else has to be true, and the answer has a name that predates web applications entirely.

An information security strategy that integrates people, technology, and operations capabilities to establish variable barriers across multiple layers and missions of the organization.

NIST SP 800-53 Rev. 5, "defense-in-depth" csrc.nist.gov/glossary/term/defense_in_depth

Variable barriers across multiple layers. The operative word is multiple. A layer is not a defense if it is the only one; it is a single point of failure wearing a security badge.

Authentication is not authorization

These two words are used interchangeably in conversation and they are not interchangeable at all. They answer different questions, fail in different ways, and are implemented by different code in different places.

Authorization may be defined as "the process of verifying that a requested action or service is approved for a specific entity".

Authorization is distinct from authentication which is the process of verifying an entity's identity.

OWASP Cheat Sheet Series, Authorization cheatsheetseries.owasp.org/…/Authorization_Cheat_Sheet.html
 AuthenticationAuthorization
Question Who is calling? May this caller do this, to this?
Answer lives in A signature, an expiry, an issuer A policy the service owns
Runs Once per request, generically Per route, per method, per record
Failure looks like 401: an outsider gets in 403 that never happened: an insider does too much
Detected by Almost every test suite Almost none, because the happy path is a happy path

That last row is why this failure mode survives code review. An authentication bug breaks the login screen and someone files it within the hour. A missing authorization check breaks nothing at all. Every legitimate user sees exactly what they expect, every test passes, and the system behaves perfectly right up until someone asks a question nobody wrote code to answer.

Access control enforces policy such that users cannot act outside of their intended permissions.

… Access control is only effective in trusted server-side code or server-less API, where the attacker cannot modify the access control check or metadata.

OWASP Top 10:2021, A01 Broken Access Control owasp.org/Top10/A01_2021-Broken_Access_Control/

The layered model

Defense in depth is not a slogan about "more security". It is a specific structural claim: a request should have to satisfy an independent decision at every boundary it crosses, so that no single misconfiguration is sufficient. Six boundaries are worth naming explicitly, because teams routinely build one or two of them and assume the rest.

Defense in depth a request carrying a valid token Perimeter / network API gateway Service authentication Service authorization Domain invariants Data scoping the record Only the outermost layer decides a request carrying a valid token Perimeter / network API gateway Service authentication Service authorization Domain invariants Data scoping the record
Left: six independent decisions. Any one of them can be wrong on a Tuesday and the system still holds. Right: the same six bands, but only the first makes a decision. Every red mark is a decision that is not being made, and therefore a place where one belongs. The stack is not "insecure by configuration"; it is undefended by omission, which is a work list rather than a verdict.
LayerThe question it answersWhy it is not enough alone
1. Perimeter / network Is this traffic allowed to reach the estate at all? Says nothing about the caller. Anything already inside is trusted absolutely.
2. API gateway Is this route exposed, rate-limited, terminated correctly? Coarse-grained by design. It does not know the domain, so it cannot know the rule.
3. Service authentication Is this token signed, unexpired, from the right issuer? Proves identity only. Every valid token passes, including the wrong one.
4. Service authorization Does this caller hold the capability this operation requires? Answers "what kind of thing", never "which specific thing".
5. Domain invariants Is this transition legal for the aggregate in its current state? Enforces the model's own rules, not the caller's entitlement to trigger them.
6. Data scoping May this caller reach this row? The one most often skipped. See the next section.
Token transport: the header, never the URL

A credential crosses layers 1–3 on every request, so how it travels is part of the model. Tokens belong in the Authorization header. A query string is not a private channel: URLs are written verbatim into web-server access logs, reverse-proxy and CDN logs, APM traces, browser history, and they are handed onward in the Referer header when the page loads any third-party resource. A bearer token placed in a URL is a bearer token copied into five systems that were never designed to hold secrets and are frequently readable by people who are not on the project. The header carries the identical value and lands in none of them. Treat it as a flat rule with no exceptions worth arguing about.

Object-level authorization

Layer 4 is the one teams build first, and it is genuinely necessary: does this caller hold the report:read capability? But it is a check about a class of operation. Every user who legitimately holds report:read passes it, including when the identifier in the request belongs to someone else entirely.

Insecure Direct Object Reference (IDOR) is a vulnerability that arises when attackers can access or modify objects by manipulating identifiers used in a web application's URLs or parameters.

… To mitigate IDOR, implement access control checks for each object that users try to access.

OWASP Cheat Sheet Series, IDOR Prevention cheatsheetseries.owasp.org/cheatsheets/…

The OWASP guidance is worth reading precisely, because it prescribes something stronger than "add an if-statement". It says to determine the caller from verified session or token information rather than from anything the caller supplied, and to restrict the query itself. The framing used is a search that returns records related to the current user, rather than a search across everything followed by a filter.

Put the scope in the WHERE clause, not in an if-statement

findById(id) followed by if (!record.tenantId().equals(caller.tenantId())) throw … is better than nothing and worse than it looks. The row has already been loaded: it exists in the persistence context, in the query cache, in the SQL log, and one careless return added six months later hands it out. findScoped(id, tenantId) cannot leak what it never fetched, and the absent row naturally produces a not found rather than a forbidden, which is also the answer that reveals less.

This is the check that turns a role model into an access-control system. Without it, "user holds the read capability" and "user may read this record" are treated as the same proposition, and they have never been the same proposition.

What this shape looks like in the field

Structural review of one estate, at two stages of its build

What follows is a structural account of our own estate, written at two stages of its build. No system is identified and no configuration is reproduced. It is included because the shape recurs, and recognising the shape is the whole lesson.

The earlier system: authentication solved thoroughly. A dedicated OAuth2 authorization server, every downstream service configured as a resource server validating signed JWTs locally, and sessions stateless throughout. That half was done properly and it still runs. It is the reference the rest of the estate now builds against.

The earlier system: and then nothing behind it. A repository-wide structural search for method-level authorization annotations, and separately for role or authority expressions, returned zero occurrences across several hundred source files. Every filter chain terminated in a single blanket "require authentication" rule.

Why that is the instructive half. A full role, permission and permission-type domain was modelled in the user service, and the frontend maintained dedicated permission and role stores that drove what the interface displayed. So access control was modelled and it was rendered, but it was not enforced at any API boundary. The token worked as a gate and never as a permission source. That system was built early, before the rule at the top of this page was understood, and it is the part of the estate now carrying maintenance debt.

The newer system: the layers, applied. The backend migration is where this page's rule actually got built. Authorization is enforced inside the service, per route and per method, and object-level checks are scoped in the query rather than filtered after the fact. What it does not yet own is its own authentication service, because that has not been scoped as a requirement while the system is still proving itself. That absence is a deferred decision rather than an oversight.

Read together, the estate already holds both halves of the answer, each proven on its own. The authentication design is settled and running. The layered authorization is settled and running. What remains is to bring the OAuth2 service into the newer system and to let the earlier application consume the newer services, which are the more mature of the two. Neither half is research at this point. What is left is assembly, and the remediation below is the order to do it in.

structural scan · method-security annotations: 0 structural scan · role/authority expressions: 0 filter chains · terminal rule: authenticate-only domain · role · permission · permission-type client · permission store · role store
Why a modelled role system is not reassurance

A rich permission model is often read as evidence that access control was taken seriously, and it is evidence that somebody thought hard about the policy. But a policy that exists only in a table and a client-side store is a description of intent. It becomes a control at the moment a service refuses a request because of it, and not one line of code earlier.

Doing it properly

Four tabs, in the order you would actually do the work. The first is the starting point; the second and third build layers 4 and 6; the fourth is the presentation layer done in a way that is genuinely useful and honest about what it is.

javaResourceServerConfig.java · the starting point
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
            .authorizeHttpRequests(auth -> auth
                // One rule for the entire service. It asks whether the token is
                // valid, and nothing whatsoever about what the token permits.
                .anyRequest().authenticated()
            );
        return http.build();
    }
}
Read the Spring docs literally

Of anyRequest().authenticated() the reference states: "This tells Spring Security that any endpoint in your application requires that the security context at a minimum be authenticated in order to allow it." At a minimum is the whole sentence. Layer 3 is configured; layer 4 is not present. Every valid token reaches every endpoint, which for a first commit is correct, and for a shipped service is the gap.

Why capabilities beat roles at the presentation layer

A client that reasons about roles has to reimplement the policy, and it will drift, because the policy lives somewhere else and changes without telling it. A client that receives capabilities cannot drift: the server computes the answer once, using the code that also enforces it, and the table renders whatever came back. When the rule changes, the UI is already correct. This also scales past row actions to columns and bulk operations: a bulk control is enabled when the selection is non-empty and every selected row carries the verb, and the batch endpoint re-checks each item on arrival rather than trusting the selection.

Two checks, two jobs

The temptation, once the lesson lands, is to conclude that UI permission filtering was the problem and delete it. That is the wrong correction. The two checks are not competing implementations of one idea; they answer different questions for different audiences.

The UI check: usability

Question: what is worth offering this person right now?

Audience: a cooperative user who wants to do their job.

Failure mode: a screen full of controls that produce errors, which teaches people to click through errors, the single most expensive habit a product can install.

Trust level: none. It runs on a machine the user controls.

The API check: safety

Question: may this caller perform this operation on this record?

Audience: every caller, including ones that never rendered a page.

Failure mode: silent. Nothing breaks, no test fails, and the gap is discovered by someone who was not looking for it.

Trust level: total, because it runs where the caller cannot reach it.

Developers must never rely on client-side access control checks. While such checks may be permissible for improving the user experience, they should never be the decisive factor in granting or denying access to a resource; client-side logic is often easy to bypass.

OWASP Cheat Sheet Series, Authorization cheatsheetseries.owasp.org/…/Authorization_Cheat_Sheet.html

"Permissible for improving the user experience" is explicit permission to build the UI filter, and "never the decisive factor" is the boundary on what it may be relied upon for. Remove the API check and there is no access control at all. Remove the UI check and the product gets measurably worse while remaining exactly as secure. They cost different things and they buy different things.

Remediation, in order

Ordered deliberately. Each step is useful on its own, and each one makes the next cheaper. Nothing here requires a rewrite; every item is additive.

Canonical sources