Spring Security with Springdoc OpenAPI
So you have added Springdoc to your Spring Boot service and everything is working beautifully until you add Spring Security. Suddenly your Swagger UI returns a 401 and you are left wondering whether the problem is your security configuration, your custom error handling, or something else entirely.
I spent a frustrating afternoon on this. The fix is straightforward once you know where to look, but getting there was not.
The problem
Springdoc serves its API documentation from a handful of endpoints:
/swagger-ui/**and/swagger-ui.htmlfor the interactive UI/v3/api-docs/**for the OpenAPI spec itself
Spring Security blocks everything by default. If you have not explicitly permitted these paths, Swagger is locked out alongside everything else. Correct behaviour from Security's perspective; annoying from yours.
Permitting the Swagger endpoints
The fix lives in your SecurityFilterChain. Whitelist the Swagger paths and keep everything else authenticated:
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}A note on CSRF: disabling it entirely is fine for a dev or test environment, but think carefully before doing this in production. If your Swagger UI is not exposed in production (and it probably should not be), the point is moot.
Custom error handling gets in the way
This is the bit that caught me out. If you have overridden AuthenticationEntryPoint (or implemented a custom ErrorController), your error handling logic might intercept Swagger requests before they reach the permit rules. The symptom is Swagger returning your application's custom error response instead of the expected UI.
The workaround is to check the request path and skip your custom logic for Swagger endpoints:
private boolean isSwaggerRequest(HttpServletRequest request) {
var uri = request.getRequestURI();
return uri.startsWith("/swagger-ui") || uri.startsWith("/v3/api-docs");
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
if (isSwaggerRequest(request)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
return;
}
// your custom error logic here
}It is not elegant (a path-matching guard in your authentication entry point feels wrong), but the alternative is Swagger silently returning your custom 403 page and you spending another hour staring at the network tab.
CORS (if you need it)
If your Swagger UI runs on a different origin from your API (common when testing locally against a deployed backend), you will need CORS configuration too:
@Bean
public CorsConfigurationSource corsConfigurationSource() {
var configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept"));
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}Do not use * for allowed origins in production. Specify the origins you actually need.
Verifying the setup
Start the application and navigate to http://localhost:8080/swagger-ui.html. If it loads, you are done. If it does not, check three things:
- Do your
requestMatcherspaths match the actual Swagger endpoints? (Springdoc versions differ; check yours.) - Is a custom error handler intercepting the request before the security filter chain gets to it?
- If the UI loads but the API spec fails to fetch, it is probably CORS.
That third one is the most irritating to debug because the Swagger UI shows a generic "Failed to load" with no hint that CORS is the cause. Check the browser's network tab; the blocked preflight request will be obvious.
What I would do differently
If I were setting this up again, I would put the Swagger path constants in a shared class rather than duplicating the strings across SecurityFilterChain, AuthenticationEntryPoint, and wherever else they accumulate. Three places to update when Springdoc changes a default path is two places too many.