How do you make a Spring Security REST API return a custom JSON body on a 403 instead of the default blank page?
answer
- setStatus(403) BEFORE writing body
- ObjectMapper → getOutputStream()
- exceptionHandling(e -> e.accessDeniedHandler(...))
- filter-chain path only, not @PreAuthorize in MVC
- don't leak required role in message
basics
~10 sImplement AccessDeniedHandler, set the response status to 403, set content type to application/json, and write your JSON body. Register it via http.exceptionHandling(e -> e.accessDeniedHandler(yourHandler)).
solid answer
~30 sProvide a class implementing AccessDeniedHandler. In handle(), call response.setStatus(403), set the content type to application/json, and serialize your error DTO (e.g. with Jackson's ObjectMapper) to response.getOutputStream(). Register it in the SecurityFilterChain via http.exceptionHandling(ex -> ex.accessDeniedHandler(myHandler)). This path is used when authorization is enforced in the filter chain (authorizeHttpRequests), because the AccessDeniedException is thrown before the DispatcherServlet, so @ControllerAdvice/@ExceptionHandler never sees it. Do not set the status after writing the body, and don't try to redirect for an API — return a machine-readable body with the same envelope shape your API uses everywhere else so clients can parse it consistently.
code
java · 30 lines@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
RestAccessDeniedHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException ex) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), Map.of(
"status", 403,
"error", "forbidden",
"message", "You do not have permission to perform this action",
"path", request.getRequestURI()));
}
}
@Bean
SecurityFilterChain chain(HttpSecurity http, RestAccessDeniedHandler handler)
throws Exception {
http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.exceptionHandling(e -> e.accessDeniedHandler(handler));
return http.build();
}go deeper
Know you implement AccessDeniedHandler and register it via exceptionHandling().
Write a correct handler: status first, JSON content type, serialize with ObjectMapper; register the bean in the DSL.
Explain why the filter-chain handler and MVC @ExceptionHandler cover different denial paths and why you may need both for consistent 403s.
Design a single canonical error envelope shared across the security handlers and MVC advice, and reason about response-commit ordering and information-disclosure risk.
**Why the default is unsuitable for APIs.** `AccessDeniedHandlerImpl` either sends a bare `sendError(403)` (which the servlet container renders as an HTML error page) or forwards to an error page. Neither is useful for a JSON API client. You replace it with a handler that writes your standard error envelope. **Step 1 — implement the interface.** ```java @Component public class RestAccessDeniedHandler implements AccessDeniedHandler { private final ObjectMapper objectMapper; RestAccessDeniedHandler(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403 response.setContentType(MediaType.APPLICATION_JSON_VALUE); var body = Map.of( "status", 403, "error", "forbidden", "message", "You do not have permission to perform this action", "path", request.getRequestURI()); objectMapper.writeValue(response.getOutputStream(), body); } } ``` **Step 2 — register it.** ```java http.exceptionHandling(e -> e.accessDeniedHandler(restAccessDeniedHandler)); ``` **Ordering gotchas.** - Set the status **before** writing the body. Once the response is committed (body flushed), you cannot change the status code. - Do **not** reveal the required authority in the message (information disclosure). A generic message is safer. - Use `response.getOutputStream()` or `getWriter()`, not both — the servlet spec forbids mixing them on one response. **Where this handler applies vs. where it doesn't.** The `AccessDeniedHandler` is only reached when the `AccessDeniedException` propagates up to the `ExceptionTranslationFilter` in the **filter chain**. That happens for URL-based rules (`authorizeHttpRequests`) and for the `AuthorizationFilter`. If instead the denial comes from **method security** (`@PreAuthorize` on a controller/service invoked *inside* the DispatcherServlet), the `AuthorizationDeniedException` is thrown during controller execution and is caught by Spring MVC's `HandlerExceptionResolver` first — so a `@ExceptionHandler(AccessDeniedException.class)` in a `@ControllerAdvice` handles it, and your `AccessDeniedHandler` does **not** run. To get consistent 403 JSON everywhere you often need **both**: a filter-chain `AccessDeniedHandler` and an MVC exception handler that returns the same envelope. **Reusing bean instances.** Register the same `@Component` in the DSL by injecting it into the `SecurityFilterChain` bean method; don't `new` it up inline if it needs Jackson or other beans. **Alternative for simple apps.** `accessDeniedPage("/error/403")` forwards to an MVC view — fine for server-rendered apps, wrong for pure APIs.
- Why might your custom AccessDeniedHandler not fire for a @PreAuthorize denial on a controller method?Because that denial (AuthorizationDeniedException) is thrown during controller execution inside the DispatcherServlet and is caught by Spring MVC's HandlerExceptionResolver/@ExceptionHandler before it can propagate back to the ExceptionTranslationFilter. The filter-chain handler only sees exceptions thrown in the filter chain.
- What happens if you set the response status after writing the JSON body?Nothing — the response is already committed once the body is flushed, so the status change is ignored and the client may see an inconsistent or default status. Always set status and content type first.
saying these in an interview costs you the question
- Redirecting to a login page on 403 for an API
- Writing the body before setting the status
- Assuming the handler catches @PreAuthorize denials thrown inside controllers
- Mixing getWriter() and getOutputStream()
- Echoing the required role/authority back to the caller