Secure AI agent APIs with Spring Security 7 - RBAC, method security, OAuth2, and per-user agent access control
90
90%
Does it follow best practices?
Impact
92%
1.24xAverage score across 3 eval scenarios
Passed
No known issues
| Component | Version | Notes |
|---|---|---|
| Spring Boot | 4.0.x | Ships with Security 7 auto-config |
| Spring Security | 7.0.x | Requires Java 17+ |
| Spring AI | 1.1.x+ | Tool/MCP security support |
| Jackson | 3.x | Default in Security 7 (Jackson 2 compat available) |
Follow these steps in order. Validate each checkpoint before proceeding.
SecurityFilterChain bean — checkpoint: app starts, all endpoints return 401UserDetailsService) — checkpoint: can authenticate<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- For JWT/OAuth2 resource server -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>Spring Security 7 removes and() chaining and authorizeRequests(). Use lambda DSL with authorizeHttpRequests() exclusively. MvcRequestMatcher and AntPathRequestMatcher are replaced by PathPatternRequestMatcher.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // activates @PreAuthorize, @PostAuthorize
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // stateless API
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/chat/**").authenticated()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/tools/**").hasAnyRole("AGENT", "ADMIN")
.anyRequest().denyAll()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}Use hasRole("ADMIN") (auto-prefixes ROLE_) or hasAuthority("ROLE_ADMIN") (literal match). For fine-grained permissions, use authorities directly:
// In SecurityFilterChain
.requestMatchers("/api/tools/execute").hasAuthority("tool:execute")
.requestMatchers("/api/tools/manage").hasAuthority("tool:manage")Define a role hierarchy so ADMIN inherits AGENT permissions:
@Bean
RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.fromHierarchy("""
ROLE_ADMIN > ROLE_AGENT
ROLE_AGENT > ROLE_USER
""");
}This is the key pattern for AI agent RBAC: annotating Spring AI tool methods with security annotations so that the authenticated user's roles determine which tools the agent can invoke.
Enable method security with @EnableMethodSecurity on your configuration class (shown above). Then annotate tool methods:
@Component
public class AgentTools {
@Tool(description = "Query the knowledge base")
@PreAuthorize("hasRole('USER')")
public String searchKnowledge(String query) {
var user = SecurityContextHolder.getContext().getAuthentication();
// scoped to authenticated user
return knowledgeService.search(query, user.getName());
}
@Tool(description = "Execute database operations")
@PreAuthorize("hasRole('AGENT')")
public String executeDatabaseQuery(String sql) {
return dbService.executeReadOnly(sql);
}
@Tool(description = "Deploy to production")
@PreAuthorize("hasRole('ADMIN')")
public String deployToProduction(String version) {
return deployService.deploy(version);
}
@Tool(description = "Read user profile")
@PostAuthorize("returnObject.owner == authentication.name")
public UserProfile getUserProfile(String userId) {
return userService.findById(userId);
}
}Create reusable meta-annotations to avoid repeating expressions:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('AGENT')")
public @interface AgentOnly {}
// Usage:
@Tool(description = "Summarize document")
@AgentOnly
public String summarize(String docId) { ... }When a user without the required role triggers a chat that tries to call a protected tool, Spring Security throws AccessDeniedException. The AI model receives a tool error and should fall back gracefully.
For development/testing, use in-memory users:
@Bean
UserDetailsService userDetailsService(PasswordEncoder encoder) {
var user = User.withUsername("alice")
.password(encoder.encode("password"))
.roles("USER")
.build();
var agent = User.withUsername("agent-1")
.password(encoder.encode("secret"))
.roles("USER", "AGENT")
.build();
var admin = User.withUsername("admin")
.password(encoder.encode("admin"))
.roles("USER", "AGENT", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, agent, admin);
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}For production, implement UserDetailsService backed by your database or integrate with an OAuth2 provider.
Configure the JWT issuer in application.yml:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
# or specify JWK set directly:
# jwk-set-uri: https://auth.example.com/.well-known/jwks.jsonTo extract roles from JWT claims, provide a custom JwtAuthenticationConverter:
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}Then reference it in the filter chain:
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)Pass the authenticated user's context into ChatClient so the AI model operates within the user's permission scope:
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatClient chatClient;
private final AgentTools agentTools;
public ChatController(ChatClient.Builder builder, AgentTools agentTools) {
this.chatClient = builder.build();
}
@PostMapping
public String chat(@RequestBody ChatRequest request, Authentication authentication) {
String systemPrompt = """
You are an assistant for user: %s (roles: %s).
Only call tools the user is authorized to use.
If a tool call fails with AccessDenied, explain the user lacks permission.
""".formatted(
authentication.getName(),
authentication.getAuthorities()
);
return chatClient.prompt()
.system(systemPrompt)
.user(request.message())
.tools(agentTools)
.call()
.content();
}
}The method-level security annotations on AgentTools enforce RBAC at the tool execution layer.
For a full migration guide, see MIGRATION.md. Key changes when migrating to Security 7 (Boot 4):
and() removed from HttpSecurity DSL. Use lambda configuration exclusively.authorizeRequests() removed. Use authorizeHttpRequests().MvcRequestMatcher / AntPathRequestMatcher removed. Use PathPatternRequestMatcher (auto-selected by requestMatchers()).AuthorizationManager#check removed. Use AuthorizationManager#authorize.AccessDecisionManager / AccessDecisionVoter moved to spring-security-access module (legacy).