
Bulletproof Error Handling: Warum deine WebFlux-API bei Security-Fehlern kein JSON spricht
In reaktiven REST-Projekten mit Spring WebFlux führen Standard-Security-Einstellungen oft zu Fehlern im Frontend (z. B. Unexpected end of JSON input), da sie auf Redirects oder Plain-Text setzen. Dieser Artikel erklärt, warum klassische @ErrorHandler in der Security-Chain versagen und wie man mit einem ServerAuthenticationEntryPoint eine konsistente, OpenAPI-konforme Fehlerstruktur implementiert.
Beitrag teilen auf
Das Problem: Die unsichtbare Mauer
Wer eine REST-API mit Spring Boot und WebFlux baut, verlässt sich meist auf @RestControllerAdvice, um Exceptions in schicke JSON-Objekte zu verwandeln. Doch sobald die Spring Security Filter Chain ins Spiel kommt, herrscht Funkstille.
Wenn ein Request wegen eines abgelaufenen Tokens oder fehlender Rechte abgelehnt wird, erreicht er niemals den Controller-Layer. Die Folge: Der @RestControllerAdvice wird ignoriert. Stattdessen greifen die Default-Mechanismen der Security-Chain:
- Redirects: Die API antwortet mit 302 Found und leitet auf eine HTML-Login-Seite um.
- Leere Bodies: Ein 401 Unauthorized ohne Inhalt wird gesendet.
Beides führt in modernen Frontends (z. B. mit Auth.js oder React) zu Parsing-Fehlern, da diese ein JSON-Objekt erwarten, aber "Nichts" oder "HTML" erhalten.
Die Architektur: Filter vs. Controller
Der Grund für dieses Verhalten liegt in der Architektur. Die Security-Filter liegen vor dem DispatcherHandler. Exceptions, die hier geworfen werden (z. B. eine ExpiredJwtException), werden von der Filter-Kette selbst abgefangen und verarbeitet.
Die Lösung: Custom Entry Points
Um die volle Kontrolle zu behalten, müssen wir die Fehlerbehandlung direkt in die Security-Konfiguration integrieren. Wir nutzen dafür den ServerAuthenticationEntryPoint (für 401) und den ServerAccessDeniedHandler (für 403).
1. Ein konsistentes Modell (OpenAPI)
Nutze einen Java Record oder ein generiertes DTO aus deiner OpenAPI-Spezifikation, um sicherzustellen, dass jeder Fehler dem API-Vertrag entspricht.
public record ApiErrorResponse(
String requestId,
OffsetDateTime timestamp,
int status,
String code,
String message
) {}
2. Der reaktive Handler
Da wir in WebFlux arbeiten, gibt unser Handler ein Mono<Void> zurück. Wir schreiben das JSON direkt in den Response-Buffer:
private Mono<Void> writeJsonResponse(ServerWebExchange exchange, HttpStatus status, String errorCode, String msg) {
return Mono.defer(() -> {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(status);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
var payload = new ApiErrorResponse(
exchange.getRequest().getId(),
OffsetDateTime.now(ZoneOffset.UTC),
status.value(),
errorCode,
msg
);
try {
byte[] bytes = objectMapper.writeValueAsBytes(payload);
DataBuffer buffer = response.bufferFactory().wrap(bytes);
return response.writeWith(Mono.just(buffer));
} catch (JsonProcessingException e) {
return Mono.error(e);
}
});
}
3. Einbindung in die Security-Chain
Damit dieser Handler Vorrang vor den Spring-Defaults hat, muss er explizit in der SecurityWebFilterChain registriert werden – insbesondere innerhalb des oauth2ResourceServer.
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.authorizeExchange(ex -> ex.anyExchange().authenticated())
.oauth2ResourceServer(oauth -> oauth
.authenticationEntryPoint((ex, authEx) -> writeJsonResponse(ex, HttpStatus.UNAUTHORIZED, "ERR-001", authEx.getMessage()))
)
.exceptionHandling(exHandling -> exHandling
.accessDeniedHandler((ex, deniedEx) -> writeJsonResponse(ex, HttpStatus.FORBIDDEN, "ERR-002", "Access Denied"))
)
.build();
}
Fazit
Ein robuster REST-Service zeichnet sich dadurch aus, dass er in jeder Situation – ob Erfolg oder Fehler – die Sprache seiner Clients spricht. Durch das manuelle Übersteuern der Security-Entry-Points verhindern wir "Unexpected end of JSON" Fehler und schaffen eine verlässliche Schnittstelle für unsere Frontend-Kollegen.