Skip to content
Yassir Halaoui

Secure integration · 5 min read

The security boundary map for a regulated Spring service

By Yassir HalaouiPublished

In short

A regulated Spring service has four distinct security boundaries and each needs its own mechanism: the network edge is mTLS between machines, the user edge is OIDC establishing who is acting, the authorization layer is RBAC deciding what that identity may do, and the data layer is encryption plus an audit trail proving what happened. Collapsing any two of them — most commonly treating a valid token as authorization — is the design error behind most findings in a security review.

Security reviews of Spring services tend to produce the same findings, and almost all of them come from one design error: two boundaries treated as one.

Here is the map I draw before writing any of the configuration.

Four boundaries, four mechanisms

BoundaryQuestion it answersMechanismFails as
NetworkWhich machine is calling?mTLSHandshake refused
IdentityWho is acting?OIDC / OAuth 2.0401 Unauthorized
AuthorizationMay this identity do this?RBAC, scopes, ownership403 Forbidden
DataWhat actually happened, and can we prove it?Encryption, audit trailNothing visible — the worst kind

They are independent. A caller can be on a trusted machine (network passes) with a valid token (identity passes) requesting something it has no right to (authorization must fail). Every one of those is a separate decision and each needs its own code path.

The error: a valid token is not authorization

This is the finding I have seen most often, and it is easy to write by accident.

// Authenticated. Not authorized. These are not the same thing.
@GetMapping("/accounts/{id}")
Account get(@PathVariable String id) {
  return accounts.findByIdOrThrow(id);
}

Spring Security is configured, the endpoint requires a token, the token is validated against the issuer. Every one of those controls works. And any authenticated user can read any account by changing the path variable.

The token proved who. Nothing checked whether.

@GetMapping("/accounts/{id}")
@PreAuthorize("hasRole('ACCOUNT_READ')")
Account get(@PathVariable String id, @AuthenticationPrincipal Jwt jwt) {
  var account = accounts.findByIdOrThrow(id);
 
  // Role-level permission is not enough on its own: this identity
  // needs a relationship to this specific record.
  if (!ownership.canAccess(jwt.getSubject(), account)) {
    throw new AccessDeniedException("Not permitted for this account");
  }
 
  return account;
}

Two checks, because there are two questions. hasRole answers "may this kind of user read accounts". The ownership check answers "may this user read this account". A system with only the first has an insecure direct object reference on every endpoint that takes an id.

Where each mechanism belongs

mTLS lives at the edge, and only at the edge. It authenticates machines. It tells you nothing about the human or the intent behind the call, so a service that treats "the connection is mutually authenticated" as authorization has granted every caller on that channel full access. Inside a service mesh with automatic mTLS this is a particularly easy mistake, because the transport security is invisible and feels like it is doing more than it is.

OIDC establishes identity, and its output is claims. The subject, the issuer, the audience, the expiry. Validate all four — an unvalidated aud means a token minted for a different service is accepted by yours, which is a genuine cross-service escalation and a common finding.

RBAC turns claims into decisions, and the decision belongs next to the resource, not in a gateway. A gateway can enforce coarse rules; it cannot know that account 4471 belongs to this customer. Enforce at the edge and in the service, and treat the edge as defence in depth rather than as the control.

The data layer proves it happened. Authorization decides; the audit trail records. Both matter in review, and the second is what you are asked for months later — the shape I use is in an audit trail that survives a compliance review.

A resource server that validates properly

SecurityConfig.java
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
  return http
      .authorizeHttpRequests(auth -> auth
          .requestMatchers("/actuator/health").permitAll()
          .anyRequest().authenticated())          // deny by default
      .oauth2ResourceServer(oauth -> oauth
          .jwt(jwt -> jwt.jwtAuthenticationConverter(rolesConverter())))
      .sessionManagement(s -> s
          .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .csrf(AbstractHttpConfigurer::disable)      // stateless + no cookies
      .build();
}
 
@Bean
JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
  var decoder = NimbusJwtDecoder
      .withIssuerLocation(props.getJwt().getIssuerUri())
      .build();
 
  decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
      JwtValidators.createDefaultWithIssuer(props.getJwt().getIssuerUri()),
      // Reject tokens minted for another service.
      new JwtClaimValidator<List<String>>("aud",
          aud -> aud != null && aud.contains("accounts-api"))));
 
  return decoder;
}

Three things worth noticing.

anyRequest().authenticated() before any permit rule. Deny by default means a new endpoint is protected the moment it exists, rather than protected once somebody remembers to add a matcher.

The audience validator is explicit. createDefaultWithIssuer checks the issuer and expiry but not the audience. Adding it is four lines and closes a real gap.

CSRF disabled only because the service is stateless and takes no cookies. If either of those changes, this line becomes a vulnerability. It deserves a comment saying so, because the person who adds cookie auth in a year will not re-derive the reasoning.

Boundaries between services

The same map applies internally, and the temptation to skip layers is stronger because "it is all inside our network".

Service-to-service calls need their own identity — a client credentials token or a certificate, not a shared secret in a header and not an assumption based on source IP. Which of the two, and why regulated counterparties usually want both, is the subject of service-to-service auth when the other side is a bank.

The rule that keeps this honest: a service should be safe if you put it directly on the internet. Network position is defence in depth, never the control. Anything that depends on being unreachable is one misconfigured ingress away from being wrong.

Propagation, and what not to propagate

A request that crosses three services should carry the original identity, not be re-minted as a generic service identity that erases who started it. Otherwise the audit trail on the third service records "the API gateway did it", which answers none of the questions an auditor asks.

Token exchange or on-behalf-of flows preserve that chain. They cost complexity, and the alternative costs the ability to answer who actually did something.

Propagate the correlation id too — the same one the audit trail records — so the log pipeline and the audit table join without anyone reconstructing timestamps by hand.

Written by Yassir Halaoui, Senior Fullstack Java / React Developer in Paris. Nine years across banking, payments and insurance, and three products built and operated solo. Get in touch.

More on secure integration