CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/spring-security-ai

Secure AI agent APIs with Spring Security 7 - RBAC, method security, OAuth2, and per-user agent access control

90

1.24x
Quality

90%

Does it follow best practices?

Impact

92%

1.24x

Average score across 3 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/spring-security-ai/

name:
spring-security-ai
description:
Secure Spring AI agent endpoints with Spring Security 7. Use when implementing RBAC for AI agents, per-user tool access, OAuth2 authentication, method-level security on tool methods, or securing ChatClient endpoints.

Securing Spring AI Agents with Spring Security 7

Version Matrix

ComponentVersionNotes
Spring Boot4.0.xShips with Security 7 auto-config
Spring Security7.0.xRequires Java 17+
Spring AI1.1.x+Tool/MCP security support
Jackson3.xDefault in Security 7 (Jackson 2 compat available)

Quick-Start Workflow

Follow these steps in order. Validate each checkpoint before proceeding.

  1. Add dependencies (Boot starter + OAuth2 resource server)
  2. Create SecurityFilterChain bean — checkpoint: app starts, all endpoints return 401
  3. Add user store (in-memory or DB-backed UserDetailsService) — checkpoint: can authenticate
  4. Configure RBAC rules on HTTP endpoints — checkpoint: role-restricted paths enforce access
  5. Enable method security and annotate tool methods — checkpoint: tool calls respect roles
  6. Wire authenticated user context into ChatClient — checkpoint: AI responses are user-scoped

Dependencies

<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>

SecurityFilterChain for REST APIs

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();
    }
}

RBAC: Roles and Authorities

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
    """);
}

Method-Level Security on Tool Methods

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.

UserDetails and User Management

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.

JWT / OAuth2 Resource Server

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.json

To 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()))
)

Securing ChatClient Per Authenticated User

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.

Key Breaking Changes from Spring Security 6.x

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.
  • Jackson 3 is default. SecurityJackson2Modules replaced. Sessions serialized with Jackson 2 are still deserializable.
  • PKCE enabled by default for all OAuth2 authorization code flows.
  • Password grant removed. Use authorization code + PKCE or client credentials.
  • AccessDecisionManager / AccessDecisionVoter moved to spring-security-access module (legacy).

skills

spring-security-ai

tile.json