spring-boot-admin-docs/src/site/docs/05-security/30-csrf-protection.md
Configure Cross-Site Request Forgery (CSRF) protection for Spring Boot Admin while allowing client registration.
Spring Boot Admin Server needs CSRF protection for the web UI, but must exempt certain endpoints:
/instances - Client registration endpoint (POST)/instances/* - Client deregistration endpoint (DELETE)/actuator/** - Admin Server's own actuator endpointsgraph TB
A["**Browser Admin UI**
• Sends CSRF token in requests
• Token stored in XSRF-TOKEN cookie
• Angular/React reads cookie and sends X-XSRF-TOKEN header"] --> B["**Spring Boot Admin Server**
• Validates CSRF token for UI requests
• Exempts /instances endpoint client registration
• Exempts /actuator/** health checks"]
C["**Client Application**
• Registers without CSRF token
• Uses HTTP POST to /instances"] --> B
CSRF attacks trick authenticated users into performing unwanted actions:
CSRF tokens prevent this:
package com.example.admin;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import de.codecentric.boot.admin.server.config.AdminServerProperties;
import static org.springframework.http.HttpMethod.DELETE;
import static org.springframework.http.HttpMethod.POST;
@Configuration
public class SecurityConfig {
private final AdminServerProperties adminServer;
public SecurityConfig(AdminServerProperties adminServer) {
this.adminServer = adminServer;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathPatternRequestMatcher.withDefaults()
.matcher(adminServer.path("/assets/**")))
.permitAll()
.requestMatchers(PathPatternRequestMatcher.withDefaults()
.matcher(adminServer.path("/login")))
.permitAll()
.anyRequest().authenticated()
)
.formLogin(formLogin -> formLogin
.loginPage(adminServer.path("/login"))
)
.httpBasic(Customizer.withDefaults());
// Custom CSRF filter to expose token to JavaScript
http.addFilterAfter(new CustomCsrfFilter(), BasicAuthenticationFilter.class);
// CSRF configuration
http.csrf(csrf -> csrf
// Use cookie-based token repository (accessible to JavaScript)
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// Use attribute-based token handler
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
// Exempt specific endpoints
.ignoringRequestMatchers(
// Client registration
PathPatternRequestMatcher.withDefaults()
.matcher(POST, adminServer.path("/instances")),
// Client deregistration
PathPatternRequestMatcher.withDefaults()
.matcher(DELETE, adminServer.path("/instances/*")),
// Notification endpoints
PathPatternRequestMatcher.withDefaults()
.matcher(POST, adminServer.path("/notifications/**")),
PathPatternRequestMatcher.withDefaults()
.matcher(DELETE, adminServer.path("/notifications/**")),
// Admin Server's own actuator
PathPatternRequestMatcher.withDefaults()
.matcher(adminServer.path("/actuator/**"))
)
);
return http.build();
}
}
The Admin UI (JavaScript) needs access to the CSRF token. Create a filter to expose it:
package com.example.admin;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.WebUtils;
public class CustomCsrfFilter extends OncePerRequestFilter {
public static final String CSRF_COOKIE_NAME = "XSRF-TOKEN";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// Get CSRF token from request attributes
CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if (csrf != null) {
Cookie cookie = WebUtils.getCookie(request, CSRF_COOKIE_NAME);
String token = csrf.getToken();
// Set cookie if not present or token changed
if (cookie == null || token != null && !token.equals(cookie.getValue())) {
cookie = new Cookie(CSRF_COOKIE_NAME, token);
cookie.setPath("/");
response.addCookie(cookie);
}
}
filterChain.doFilter(request, response);
}
}
What this does:
XSRF-TOKENGET / HTTP/1.1
Response:
HTTP/1.1 200 OK
Set-Cookie: XSRF-TOKEN=abc123; Path=/
Set-Cookie: JSESSIONID=xyz789; Path=/; HttpOnly
<!DOCTYPE html>
<html>...</html>
Admin UI JavaScript reads XSRF-TOKEN cookie and sends it in header:
fetch('/instances/123/actuator/info', {
method: 'GET',
headers: {
'X-XSRF-TOKEN': 'abc123' // From cookie
},
credentials: 'same-origin'
})
HTTP Request:
GET /instances/123/actuator/info HTTP/1.1
X-XSRF-TOKEN: abc123
Cookie: XSRF-TOKEN=abc123; JSESSIONID=xyz789
Spring Security compares:
X-XSRF-TOKEN headerXSRF-TOKEN cookieIf they match, request is allowed.
Client applications register without CSRF token:
POST /instances HTTP/1.1
Content-Type: application/json
{
"name": "my-service",
"healthUrl": "http://localhost:8081/actuator/health"
}
This works because /instances is in ignoringRequestMatchers.
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
Pros:
Cons:
.csrfTokenRepository(new HttpSessionCsrfTokenRepository())
Pros:
Cons:
Spring Boot Admin requires cookie-based because the UI is a JavaScript SPA.
.ignoringRequestMatchers(
PathPatternRequestMatcher.withDefaults()
.matcher(POST, adminServer.path("/instances")),
PathPatternRequestMatcher.withDefaults()
.matcher(DELETE, adminServer.path("/instances/*"))
)
Why?
.ignoringRequestMatchers(
PathPatternRequestMatcher.withDefaults()
.matcher(adminServer.path("/actuator/**"))
)
Why?
.ignoringRequestMatchers(
PathPatternRequestMatcher.withDefaults()
.matcher(POST, adminServer.path("/notifications/**")),
PathPatternRequestMatcher.withDefaults()
.matcher(DELETE, adminServer.path("/notifications/**"))
)
Why?
If using a custom context path:
spring:
boot:
admin:
context-path: /admin
Use adminServer.path() to include context path automatically:
PathPatternRequestMatcher.withDefaults()
.matcher(POST, adminServer.path("/instances"))
This becomes /admin/instances automatically.
Without token:
curl -X POST http://localhost:8080/applications/my-app/restart \
-H "Cookie: JSESSIONID=abc123"
Response: 403 Forbidden
With token:
curl -X POST http://localhost:8080/applications/my-app/restart \
-H "Cookie: JSESSIONID=abc123; XSRF-TOKEN=def456" \
-H "X-XSRF-TOKEN: def456"
Response: 200 OK
curl -X POST http://localhost:8080/instances \
-H "Content-Type: application/json" \
-d '{
"name": "test-service",
"healthUrl": "http://localhost:8081/actuator/health"
}'
Response: 201 Created (no CSRF token needed)
curl http://localhost:8080/actuator/health
Response: 200 OK (no CSRF token needed)
For development/testing only:
@Bean
@Profile("dev")
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(/* ... */)
.csrf(csrf -> csrf.disable()); // Disable CSRF
return http.build();
}
Only disable CSRF for development and testing.
Enhance CSRF protection with SameSite cookies:
server:
servlet:
session:
cookie:
same-site: strict
Options:
strict: Cookie only sent for same-site requests (most secure)lax: Cookie sent for top-level navigation (default)none: Cookie sent for all requests (requires secure=true)Recommendation: Use lax for Admin Server (allows direct navigation).
Cause: CSRF token missing or invalid.
Check:
Cookie is set:
curl -i http://localhost:8080/
Should see Set-Cookie: XSRF-TOKEN=...
Custom CSRF filter is registered:
http.addFilterAfter(new CustomCsrfFilter(), BasicAuthenticationFilter.class)
Token repository is cookie-based:
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
Cause: /instances endpoint not exempted from CSRF.
Solution: Add to ignoringRequestMatchers:
.csrf(csrf -> csrf
.ignoringRequestMatchers(
PathPatternRequestMatcher.withDefaults()
.matcher(POST, adminServer.path("/instances")),
PathPatternRequestMatcher.withDefaults()
.matcher(DELETE, adminServer.path("/instances/*"))
)
)
Cause: Cookie is HTTP-only.
Solution: Use withHttpOnlyFalse():
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
Expected behavior. Spring Security generates new tokens regularly for security.
If problematic: Use CookieCsrfTokenRepository with custom settings:
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookieName("XSRF-TOKEN");
repository.setHeaderName("X-XSRF-TOKEN");
Cause: Matchers don't include context path.
Solution: Use adminServer.path():
PathPatternRequestMatcher.withDefaults()
.matcher(POST, adminServer.path("/instances"))
Not:
new AntPathRequestMatcher("/instances", POST.name())
CookieCsrfTokenRepository repository = new CookieCsrfTokenRepository();
repository.setCookieName("MY-CSRF-TOKEN");
repository.setHeaderName("X-MY-CSRF-TOKEN");
repository.setParameterName("_csrf");
repository.setCookieHttpOnly(false);
http.csrf(csrf -> csrf
.csrfTokenRepository(repository)
)
Update CustomCsrfFilter accordingly:
public static final String CSRF_COOKIE_NAME = "MY-CSRF-TOKEN";
Enable CSRF only for browser requests:
http.csrf(csrf -> csrf
.requireCsrfProtectionMatcher(request -> {
// Require CSRF for browser requests (non-API)
String method = request.getMethod();
if ("GET".equals(method) || "HEAD".equals(method) ||
"TRACE".equals(method) || "OPTIONS".equals(method)) {
return false; // Safe methods
}
String header = request.getHeader("X-Requested-With");
if ("XMLHttpRequest".equals(header)) {
return true; // AJAX requests
}
String accept = request.getHeader("Accept");
if (accept != null && accept.contains("application/json")) {
return false; // API clients
}
return true; // Browser requests
})
)
Separate CSRF rules for UI and API:
@Configuration
public class SecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http,
AdminServerProperties adminServer) throws Exception {
http
.securityMatcher(adminServer.path("/api/**"))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()); // No CSRF for API
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain uiFilterChain(HttpSecurity http,
AdminServerProperties adminServer) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(/* ... */)
.csrf(csrf -> csrf // CSRF for UI
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
}
SecurityConfig.java:
package com.example.admin;
import java.util.UUID;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import de.codecentric.boot.admin.server.config.AdminServerProperties;
import static org.springframework.http.HttpMethod.DELETE;
import static org.springframework.http.HttpMethod.POST;
@Configuration
public class SecurityConfig {
private final AdminServerProperties adminServer;
public SecurityConfig(AdminServerProperties adminServer) {
this.adminServer = adminServer;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
SavedRequestAwareAuthenticationSuccessHandler successHandler =
new SavedRequestAwareAuthenticationSuccessHandler();
successHandler.setTargetUrlParameter("redirectTo");
successHandler.setDefaultTargetUrl(adminServer.path("/"));
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathPatternRequestMatcher.withDefaults()
.matcher(adminServer.path("/assets/**")))
.permitAll()
.requestMatchers(PathPatternRequestMatcher.withDefaults()
.matcher(adminServer.path("/login")))
.permitAll()
.requestMatchers(PathPatternRequestMatcher.withDefaults()
.matcher(adminServer.path("/actuator/info")))
.permitAll()
.requestMatchers(PathPatternRequestMatcher.withDefaults()
.matcher(adminServer.path("/actuator/health")))
.permitAll()
.anyRequest().authenticated()
)
.formLogin(formLogin -> formLogin
.loginPage(adminServer.path("/login"))
.successHandler(successHandler)
)
.logout(logout -> logout
.logoutUrl(adminServer.path("/logout"))
)
.httpBasic(Customizer.withDefaults());
http.addFilterAfter(new CustomCsrfFilter(), BasicAuthenticationFilter.class)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
.ignoringRequestMatchers(
PathPatternRequestMatcher.withDefaults()
.matcher(POST, adminServer.path("/instances")),
PathPatternRequestMatcher.withDefaults()
.matcher(DELETE, adminServer.path("/instances/*")),
PathPatternRequestMatcher.withDefaults()
.matcher(adminServer.path("/actuator/**"))
)
);
http.rememberMe(rememberMe -> rememberMe
.key(UUID.randomUUID().toString())
.tokenValiditySeconds(1209600)
);
return http.build();
}
@Bean
public InMemoryUserDetailsManager userDetailsService(PasswordEncoder passwordEncoder) {
UserDetails user = User.builder()
.username("admin")
.password(passwordEncoder.encode(System.getenv("ADMIN_PASSWORD")))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
CustomCsrfFilter.java (same as shown earlier).