Most Spring Boot apps aren’t “hacked.” They’re misconfigured. A role check sits in a controller, a permissive matcher slips into the HTTP config, or a service trusts a user‑supplied ID. Nothing exotic—just enough to leak data when someone guesses another identifier or replays a token.
The root cause: trusting a single guard (usually the web layer) instead of telling the same authorization story at the route, method, and data levels. The fix isn’t fancy; it’s layered and boring on purpose.
If a request can reference it, your code must prove ownership before returning it.
How we end up with leaky authorization
Security incidents in CRUD apps rarely begin with a super-hacker. They start with a reasonable feature request and a deadline. We add an endpoint, check a role in the controller, and ship. Months later a support ticket reveals a user can pass a different ID and suddenly see someone else’s data. Nothing was “hacked”—we simply trusted the request and forgot that the web layer is only one guard on the door.
The client is not a source of truth. Controllers are not the only place attackers can slip through. The mistake is assuming that “we checked once” means “we’re safe everywhere.” In practice, authorization needs to be reinforced at each layer that touches a decision: the route, the method, and the data.
What defense in depth looks like in a Spring app
Think of your application as a set of three checkpoints. At the HTTP boundary, you keep public things public and everything else authenticated, with the most specific paths first. At the service boundary, you enforce who may call which business operation, not just which URL they hit. And at the data boundary, you don’t fetch broadly and filter in memory—you ask the database for exactly “this thing owned by this principal,” so there’s nothing to leak in the first place.
Shaping the perimeter: HTTP security (Spring Security 6)
A clear perimeter doesn’t mean complicated rules; it means precise ones. Specific matchers first, public endpoints explicit, everything else authenticated and stateless for APIs. The configuration below expresses exactly that intent.
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // JWT-based APIs are stateless
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(reg -> reg
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/public/**", "/actuator/health").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt())
.build();
}
The usual misconfigurations aren’t sneaky—they’re subtle: a too-broad permitAll("/**")
, a misplaced matcher that opens a hole, or enabling sessions without meaning to. Keep the rule order intentional and defaults safe.
Ownership is a query, not a post‑it note
“We’ll fetch the order and then check if it’s yours” sounds fine until one forgotten branch forgets the check. A safer habit is to encode ownership into the query itself. That way, if the data layer doesn’t return it, the service layer has nothing to leak.
@Configuration
@EnableMethodSecurity
class MethodSecurityConfig { }
@Service
class OrderService {
private final OrderRepository repo;
OrderService(OrderRepository repo) { this.repo = repo; }
@PreAuthorize("hasRole('USER') and #userId == authentication.principal.id")
public Order findOwnedOrder(Long userId, Long orderId) {
return repo.findByIdAndUserId(orderId, userId)
.orElseThrow(() -> new AccessDeniedException("Not yours"));
}
}
Notice the double lock: a method-level guard that aligns with the caller’s identity and a repository query that encodes the same rule. Either one helps; together they make bypasses unlikely.
Tokens don’t explain themselves
JWTs carry claims, but Spring needs to be told how to turn those into authorities. Different providers use different fields and prefixes. Small mismatches—like expecting ROLE_
where none exists—lead to surprising “why is this forbidden?” bugs.
@Bean
JwtAuthenticationConverter jwtAuthConverter() {
var c = new JwtGrantedAuthoritiesConverter();
c.setAuthorityPrefix(""); // keep provider's prefix
c.setAuthoritiesClaimName("scope");
var conv = new JwtAuthenticationConverter();
conv.setJwtGrantedAuthoritiesConverter(c);
return conv;
}
Align your checks with reality: if your tokens expose scope=admin
, prefer hasAuthority("admin")
or add a prefix deliberately. Make your intent explicit and your diagnostics obvious.
A few recurring pitfalls
Actuator sprawl in production exposes more than health and info. Mock‑only tests hide real token parsing and authority mapping issues. Secrets in config linger in repos and logs—use a vault or KMS and environment variables. And if you mix session CSRF with API clients by accident, you’ll chase 403s until you standardize on a stateless approach for APIs.
Prove it in tests
It’s tempting to sprinkle @WithMockUser
everywhere and call it a day. Keep a few end‑to‑end tests that parse real JWTs, exercise your security chain, and hit the database with ownership constraints. They catch the gaps that unit tests politely ignore.
@AutoConfigureMockMvc
@SpringBootTest
class SecurityIT {
@Autowired MockMvc mvc;
@Test void rejectsUnauthorized() throws Exception {
mvc.perform(get("/api/orders")).andExpect(status().isUnauthorized());
}
@Test void allowsAdminToAdminArea() throws Exception {
mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer "+adminJwt()))
.andExpect(status().isOk());
}
}
Closing thoughts
Authorization is less a feature than a habit. When the route, the method, and the data layer all tell the same story about who may see what, accidental leaks become rare—and incident reviews become boring. That’s the goal.