Securing a Spring Boot Admin API
Hiding a button is not authorization. A walk through the controls that actually protect an admin API: stateless JWT, method-level roles, rate limiting and the mistakes that survive code review.
The most common authorization failure I find in review is not a missing check. It is a check that exists only in the front end.
The admin menu hides the delete button for editors. The delete endpoint accepts any authenticated token. Nobody notices, because nobody clicks a button that is not rendered — until someone reads the JavaScript bundle, which is public, and finds the endpoint.
Deny by default
Get the filter chain right and most of this class of bug disappears. The final rule must be anyRequest().authenticated(), so a newly added endpoint is protected until someone deliberately opens it. The reverse default — permit unless listed — fails open every time someone forgets.
@Bean
SecurityFilterChain filterChain(HttpSecurity http, CorsConfigurationSource cors)
throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // stateless bearer tokens only
.cors(c -> c.configurationSource(cors))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/v1/public/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/public/contact").permitAll()
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/refresh").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/v1/admin/**").authenticated()
.anyRequest().authenticated())
.headers(h -> h
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; frame-ancestors 'none'; object-src 'none'"))
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
Disabling CSRF is correct only because this API is stateless and authenticates with a bearer token from a header. The moment any endpoint accepts a session cookie, CSRF protection has to come back — the exemption is about the credential mechanism, not about it being an API.
Roles at the method, not just the path
Path matchers express coarse rules. Fine-grained rules belong next to the operation, where the person changing the method sees them:
@RestController
@RequestMapping("/api/v1/admin/projects")
@PreAuthorize("hasAnyRole('SUPER_ADMIN','CONTENT_ADMIN','EDITOR')")
public class AdminProjectController {
@PostMapping
@PreAuthorize("hasAnyRole('SUPER_ADMIN','CONTENT_ADMIN','EDITOR')")
public ResponseEntity<ProjectDetail> create(@Valid @RequestBody ProjectRequest request) { }
@PostMapping("/{id}/publish")
@PreAuthorize("hasAnyRole('SUPER_ADMIN','CONTENT_ADMIN')") // editors may not publish
public ProjectDetail publish(@PathVariable UUID id) { }
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('SUPER_ADMIN')") // deletion is narrower still
public ResponseEntity<Void> delete(@PathVariable UUID id) { }
}
Editors create and edit. Content admins publish. Only super admins delete. Each rule is visible at the operation it governs, which is where it will be read when someone modifies it.
Test the negative case
A test that an admin can delete proves the happy path works. It proves nothing about security. The test that matters is that an editor cannot:
@Test
@WithMockUser(roles = "EDITOR")
void editorCannotDeleteProject() throws Exception {
mockMvc.perform(delete("/api/v1/admin/projects/{id}", existingId).with(csrf()))
.andExpect(status().isForbidden());
}
@Test
void anonymousCannotReachAdminApi() throws Exception {
mockMvc.perform(get("/api/v1/admin/projects"))
.andExpect(status().isUnauthorized());
}
Write one negative test per role boundary. They are short, and they fail loudly when someone widens a @PreAuthorize without thinking through the consequence.
Short access tokens, revocable refresh tokens
Access tokens are self-contained and cannot be revoked, so keep them short — fifteen minutes is a reasonable default. Refresh tokens are long-lived, so they must be revocable, which means server-side state.
Store the hash, never the token:
public TokenPair refresh(String presentedToken, String userAgent) {
String hash = hashToken(presentedToken);
RefreshToken stored = refreshTokenRepository
.findByTokenHashAndRevokedAtIsNull(hash)
.filter(t -> t.getExpiresAt().isAfter(Instant.now()))
.orElseThrow(() -> new BadCredentialsException("Invalid refresh token"));
stored.setRevokedAt(Instant.now()); // rotate: single use
refreshTokenRepository.save(stored);
return issueTokenPair(stored.getUser(), userAgent);
}
Two properties matter here. The database holds hashes, so a database disclosure does not hand over usable tokens. And each refresh token is single use — presenting one twice means either a bug or a stolen token, and both are worth alerting on.
Rate limit the credential endpoints
Login and contact submission are the endpoints that get abused, and neither has a legitimate high-frequency use.
@Component
@RequiredArgsConstructor
public class RateLimiter {
private final StringRedisTemplate redis;
public boolean tryConsume(String bucket, String identifier, int limit, Duration window) {
String key = "rl:" + bucket + ":" + identifier;
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1L) {
redis.expire(key, window);
}
return count != null && count <= limit;
}
}
Key it on hashed client IP, keep the limit low, and return 429 with a Retry-After header rather than a generic failure — a legitimate user who mistyped a password deserves to know what happened.
The ones that survive review
Four failures I keep finding in otherwise careful codebases:
- Verbose authentication errors. "User not found" versus "wrong password" is a username oracle. One message: "Invalid email or password."
- Wildcard CORS in production.
allowedOrigins("*")withallowCredentials(true)is rejected by the spec, so someone reflects theOriginheader instead — which is worse, and is any origin. - Actuator exposure.
management.endpoints.web.exposure.include=*publishes/envand/configprops, which contain configuration you did not intend to publish. - Entities as request bodies. Binding a body onto a JPA entity is mass assignment. A client posts
"role": "SUPER_ADMIN"and the field exists, so it binds. Request records with only settable fields close this by construction.
None of these are exotic. They are all things that pass review because the code looks like security code.