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.
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."
<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.
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.
| Authentication | Authorization | |
|---|---|---|
| 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.
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.
| Layer | The question it answers | Why 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. |
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.
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.
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
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 storeA 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.
@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();
}
}
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.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // switches on @PreAuthorize, see the next tab
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(j -> j.jwtAuthenticationConverter(claimsToAuthorities())))
.authorizeHttpRequests(auth -> auth
// Pairs are evaluated top to bottom and the FIRST match wins,
// so the most specific rule has to be declared first.
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/reports/**").hasAuthority("report:read")
.requestMatchers(HttpMethod.POST, "/reports/**").hasAuthority("report:write")
.requestMatchers(HttpMethod.DELETE, "/reports/**").hasAuthority("report:admin")
// Deny by default. A route added next sprint is closed until
// somebody opens it deliberately, the exact opposite of
// authenticated(), which would have opened it on creation.
.anyRequest().denyAll()
);
return http.build();
}
}
The converter on the highlighted line is the piece people forget: authorities do not
appear by magic. Something has to translate the claims in the verified token into the
GrantedAuthority values that hasAuthority(…) compares against, and
that mapping is a deliberate, reviewable decision about which claims the service is
willing to trust.
@Service
@RequiredArgsConstructor
public class ReportService {
private final ReportRepository reports;
/**
* Layer 4: may this caller do this KIND of thing? Spring AOP evaluates
* the expression before the method body is entered, so a denial never
* reaches the repository at all.
*
* CAVEAT, and it is the one that catches people: this holds only for calls
* arriving through the Spring proxy. A self-invocation, another method on
* THIS class calling this.find(...), bypasses the proxy, and with it the
* @PreAuthorize check, silently and with no warning. Treat annotated
* methods as an external entry point only, or resolve the bean through
* AopContext / a self-reference if an internal caller genuinely needs it.
*/
@PreAuthorize("hasAuthority('report:read')")
public ReportView find(UUID reportId, AuthenticatedCaller caller) {
// Layer 6: may this caller see THIS record?
//
// `caller` is a parameter, but it is NOT client-supplied: it is
// resolved from the validated token by the argument resolver below,
// never bound from the request body, query string or a path variable.
// That distinction is the whole guarantee, so it is worth stating
// precisely rather than as "never from a parameter": parameters are
// fine; parameters populated from untrusted input are not.
return reports.findScoped(reportId, caller.tenantId())
.map(ReportView::of)
.orElseThrow(() -> new ReportNotFound(reportId));
}
}
public interface ReportRepository extends JpaRepository<Report, UUID> {
/**
* The scope lives in the WHERE clause, not in a check performed after the
* fetch. A row outside the caller's tenant is never loaded, so it cannot
* be logged, cached, or handed out by a mapper written next quarter.
*/
@Query("select r from Report r where r.id = :id and r.tenantId = :tenantId")
Optional<Report> findScoped(@Param("id") UUID id, @Param("tenantId") UUID tenantId);
}
/**
* The part the guarantee actually rests on, shown rather than assumed.
*
* The caller is built from the validated JWT and from nothing else. There is
* no setter, no binding annotation and no constructor reachable from request
* data, so no request can forge a tenant. If this resolver is wrong, every
* scoped query above is wrong with it, which is exactly why it belongs in
* the example instead of off-screen.
*/
@Component
class AuthenticatedCallerResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter p) {
return AuthenticatedCaller.class.equals(p.getParameterType());
}
@Override
public AuthenticatedCaller resolveArgument(MethodParameter p, ModelAndViewContainer m,
NativeWebRequest req, WebDataBinderFactory b) {
var jwt = (Jwt) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return new AuthenticatedCaller(
UUID.fromString(jwt.getSubject()),
UUID.fromString(jwt.getClaimAsString("tenant_id")));
}
}
/** Immutable, and constructible only from a verified token. */
public record AuthenticatedCaller(UUID userId, UUID tenantId) {}
Two independent decisions, two different questions, one method. Remove
@PreAuthorize and any authenticated caller may read reports. Remove the tenant
parameter and any caller holding report:read may read everyone's
reports. Neither check substitutes for the other, which is what "depth" means in
practice.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { listReports, exportReport, archiveReport } from '../api/reports'
/**
* A capability is a verb the SERVER has already decided this caller may
* attempt on this row. The client renders capabilities; it never derives
* them from a role string it holds locally.
*/
type Capability = 'export' | 'archive'
interface ReportRow {
id: string
title: string
owner: string
capabilities: Capability[] // sent per row, by the server, with the payload
}
const rows = ref<ReportRow[]>([])
const can = (row: ReportRow, verb: Capability) => row.capabilities.includes(verb)
// A column earns its width only when some visible row grants the capability.
const showExport = computed(() => rows.value.some(r => can(r, 'export')))
const showArchive = computed(() => rows.value.some(r => can(r, 'archive')))
onMounted(async () => { rows.value = await listReports() })
async function onExport(row: ReportRow) {
await exportReport(row.id)
}
async function onArchive(row: ReportRow) {
// The control was rendered because the server said this row allowed it.
// The server checks AGAIN on invoke: a client that fabricates the flag
// receives 403, not an archived row. That second check is the control.
await archiveReport(row.id)
}
</script>
<template>
<table>
<thead>
<tr>
<th>Title</th>
<th>Owner</th>
<th v-if="showExport">Export</th>
<th v-if="showArchive">Archive</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>{{ row.title }}</td>
<td>{{ row.owner }}</td>
<td v-if="showExport">
<button v-if="can(row, 'export')" @click="onExport(row)">CSV</button>
</td>
<td v-if="showArchive">
<button v-if="can(row, 'archive')" @click="onArchive(row)">Archive</button>
</td>
</tr>
</tbody>
</table>
</template>
Note what changed relative to the snippet at the top of this page. The component no longer consults a local role store and no longer decides anything. It receives a list of verbs the server has already evaluated for that specific row, and renders them. The loop is: server returns capabilities → client renders them → server re-validates on invoke. Every one of those arrows is required; the third is the only one that is a security control.
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.
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.
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.
"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.
- 1. Inventory before changing anything. For each service, write down which of the six layers actually makes a decision today. Most of the argument in the room disappears once the table exists.
- 2. Decide where authorities come from. Choose the claim, and write the converter that turns verified token claims into
GrantedAuthorityvalues. Every later step depends on this one being explicit. - 3. Replace the blanket rule with per-route authorities. Enumerate routes by method and required authority in the
SecurityFilterChain. This is the highest-value change per hour spent. - 4. End the chain with
denyAll(). Deny by default, so that a route added later is closed until somebody opens it. OWASP lists violating deny-by-default among the named forms of broken access control. - 5. Turn on
@EnableMethodSecurityand annotate service methods. Layer 4 belongs next to the behaviour, not only next to the route, because the same method is often reachable from more than one entry point. - 6. Push ownership and tenant scoping into the queries. Layer 6. This is the step that is genuinely tedious and genuinely irreplaceable; do it repository by repository and let the compiler help by removing the unscoped finder.
- 7. Move the token out of any URL it is still travelling in. Header only. Then rotate anything that was previously exposed to logs, because a credential that has been written to a log is no longer a secret.
- 8. Return capabilities from the read endpoints. Per record, computed by the same code that enforces the rule. This lets the UI stop guessing.
- 9. Rewrite the client to render capabilities, not roles. Delete the local permission logic once the server supplies the answer; two implementations of one policy will always diverge.
- 10. Add the negative tests. For every endpoint, one test with a valid token that lacks the authority, and one with a valid token from the wrong tenant. Both must fail closed. Without these, step 3 through step 6 quietly rot.
Canonical sources
- OWASP Top 10:2021: A01 Broken Access Control · the definition of access control, the server-side-only rule, and deny-by-default named as a failure.
- OWASP Cheat Sheet Series: Authorization · authn vs authz stated precisely, and the sentence on client-side checks that settles this page.
- OWASP Cheat Sheet Series: IDOR Prevention · per-object checks, and restricting the query rather than filtering afterwards.
- Spring Security: Authorize HTTP Requests · first-match-wins ordering, and what
authenticated()does and does not promise. - Spring Security: Method Security ·
@EnableMethodSecurity, and@PreAuthorizeevaluated before the method is invoked. - NIST CSRC glossary: defense-in-depth · the SP 800-53 Rev. 5 definition quoted above: variable barriers across multiple layers.