Quarkus Request Filters Done Right
Auditing, sanitizing, and tracing HTTP requests without blocking or breaking APIs
I ran into this problem the first time when I needed to audit incoming API requests for a regulated system.
The requirement sounded simple.
Log incoming JSON payloads.
Add a trace ID if the client did not send one.
Do not break the endpoint.
In Quarkus, this is where many developers trip.
Reading a request body too early.
Blocking the IO thread.
Or accidentally consuming the stream so the endpoint receives nothing.
In this tutorial, we will build a request auditing filter in Quarkus that:
Safely inspects incoming JSON requests
Optionally modifies the payload
Injects metadata via headers
Preserves the request so the resource can still read it
What You Will Build
We will create a pre-matching request filter that:
Intercepts incoming HTTP requests
Reads and logs JSON payloads
Optionally sanitizes sensitive fields
Injects a tracing header
Passes the request downstream without breaking it
This is a common pattern for:
Request auditing
Compliance logging
Input sanitization
Correlation and tracing
Prerequisites
To keep things predictable we will use Java 21+, Maven 3.9+, and Quarkus 3.x.
Create a new project using the Quarkus CLI or grab the code from my Github repository.
quarkus create app com.example:request-filter-demo \
--extensions="rest-jackson" \
--java=21
cd request-filter-demoThis gives us a clean foundation.
How Request Filters Work in Quarkus
Before we write code, one important concept.
In Quarkus, request filters intercept HTTP requests before they reach resource methods. You create a filter by annotating a method with @ServerRequestFilter; the method receives ContainerRequestContext (for headers, body, URI) and optionally ResourceInfo (for the matched resource method/class). Filters can read or modify the request, including the body stream. Since the body stream can only be read once, if you read it, you must replace it with a new stream containing the same data so downstream code can read it. Filters can return Uni<Void> for async work and use .runSubscriptionOn() to offload blocking I/O to worker threads, keeping the event loop non-blocking. Filters run in registration order and can conditionally execute based on annotations or other request metadata.
Implement a Safe Request Filter
We start with the most robust approach.
We explicitly move to a worker thread, read the request body, and then reset the stream so the endpoint can read it again.
Create the Filter Class
Create the following file: src/main/java/com/example/filter/RequestAuditFilter.java
package com.example.filter;
import java.io.ByteArrayInputStream;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.server.ServerRequestFilter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ResourceInfo;
@Audited
@ApplicationScoped
public class RequestAuditFilter {
private static final Logger LOG = Logger.getLogger(RequestAuditFilter.class);
@Inject
ObjectMapper objectMapper;
@ServerRequestFilter
public Uni<Void> captureAndInspect(ContainerRequestContext context, ResourceInfo resourceInfo) {
// Check if the matched resource method has @Audited annotation
if (resourceInfo != null) {
Method resourceMethod = resourceInfo.getResourceMethod();
if (resourceMethod != null) {
boolean hasAudited = resourceMethod.isAnnotationPresent(Audited.class)
|| resourceMethod.getDeclaringClass().isAnnotationPresent(Audited.class);
if (!hasAudited) {
// Skip auditing if the resource method doesn’t have @Audited
return Uni.createFrom().voidItem();
}
}
}
return Uni.createFrom().item(() -> {
byte[] originalBytes = null;
try {
// Step 1: Access the raw request body stream
var entityStream = context.getEntityStream();
// Step 2: Read all bytes (this will be executed on a worker thread via Uni)
originalBytes = entityStream.readAllBytes();
// Step 3: Avoid noise for empty bodies (e.g. GET requests)
if (originalBytes.length == 0) {
context.setEntityStream(new ByteArrayInputStream(originalBytes));
return null;
}
// Step 4: Convert to String and parse JSON
String body = new String(originalBytes, StandardCharsets.UTF_8);
try {
// Parse JSON to validate and pretty-print for audit log
JsonNode jsonNode = objectMapper.readTree(body);
String prettyJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode);
LOG.infof(”AUDIT LOG:\n%s”, prettyJson);
// In a real system, forward this to an async audit service
} catch (Exception jsonException) {
// If it’s not valid JSON, log as plain text
LOG.infof(”AUDIT LOG (non-JSON): %s”, body);
}
// Step 5: Reset the stream so downstream can read it
context.setEntityStream(
new ByteArrayInputStream(originalBytes));
return null;
} catch (Exception e) {
LOG.error(”Failed to read request body”, e);
// If reading fails, return original body if available
if (originalBytes != null) {
context.setEntityStream(new ByteArrayInputStream(originalBytes));
}
// Don’t throw exception - let the request continue
return null;
}
})
.runSubscriptionOn(Infrastructure.getDefaultWorkerPool())
.replaceWithVoid();
}
}Why This Works
runSubscriptionOn() moves execution off the IO thread — The code uses .runSubscriptionOn(Infrastructure.getDefaultWorkerPool())
The request body is read exactly once: originalBytes = entityStream.readAllBytes();
The stream is replaced with a fresh new ByteArrayInputStream containing the original bytes, so downstream can read it again.
The resource sees the request as if nothing happened — Because the original bytes are restored to a fresh stream, the resource method receives the unmodified request body.
If you forget step 4, your endpoint will receive an empty body. It resets the stream. If omitted, the stream remains consumed and the endpoint reads an empty body.
Common Inspection Patterns You Will Need
Once you can safely read the body, more advanced use cases become possible.
Let’s look at three patterns you will almost always need. But before, let’s create some name bindings so we can apply them selectively. Each “trick” gets its own annotation.
Audit Binding
The filter we already created. Make sure to add it to the RequestAuditFilter class with @Audited.
package com.example.filter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import jakarta.ws.rs.NameBinding;
@NameBinding
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
}Sanitization Binding
Trick A
package com.example.filter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import jakarta.ws.rs.NameBinding;
@NameBinding
@Retention(RetentionPolicy.RUNTIME)
public @interface Sanitized {
}Trace ID Binding
Trick B
package com.example.filter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import jakarta.ws.rs.NameBinding;
@NameBinding
@Retention(RetentionPolicy.RUNTIME)
public @interface Traced {
}Each annotation represents a capability, not an implementation detail.
Trick A: Modifying the Payload Safely - Simple Sanitization
The SanitizationFilter is a Quarkus REST request filter that sanitizes sensitive fields in JSON request bodies before they reach resource methods. It checks for the @Sanitized annotation on the resource method or class; if present, it reads the request body, parses it as JSON, and replaces sensitive field values (like passwords, tokens, API keys, and credit card numbers) with “” to prevent sensitive data from appearing in logs or being processed insecurely. The filter recursively processes nested objects and arrays, handles empty bodies and parsing errors gracefully, and runs asynchronously on a worker thread pool to avoid blocking the event loop.
package com.example.filter;
import java.io.ByteArrayInputStream;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.server.ServerRequestFilter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ResourceInfo;
@Sanitized
@ApplicationScoped
public class SanitizationFilter {
private static final Logger LOG = Logger.getLogger(SanitizationFilter.class);
private static final Set<String> SENSITIVE_FIELDS = Set.of(”password”, “secret”, “token”, “apiKey”, “apikey”,
“accessToken”, “refreshToken”, “ssn”, “creditCard”, “creditcard”);
@Inject
ObjectMapper objectMapper;
@ServerRequestFilter
public Uni<Void> sanitize(ContainerRequestContext context, ResourceInfo resourceInfo) {
// Check if the matched resource method has @Sanitized annotation
if (resourceInfo != null) {
Method resourceMethod = resourceInfo.getResourceMethod();
if (resourceMethod != null) {
boolean hasSanitized = resourceMethod.isAnnotationPresent(Sanitized.class)
|| resourceMethod.getDeclaringClass()
.isAnnotationPresent(Sanitized.class);
if (!hasSanitized) {
// Skip sanitization if the resource method doesn’t have @Sanitized
return Uni.createFrom().voidItem();
}
}
}
return Uni.createFrom().item(() -> {
byte[] originalBytes = null;
try {
LOG.infof(”SanitizationFilter LOG”);
originalBytes = context.getEntityStream().readAllBytes();
if (originalBytes.length == 0) {
// Empty body, nothing to sanitize
context.setEntityStream(new ByteArrayInputStream(originalBytes));
return null;
}
String body = new String(originalBytes, StandardCharsets.UTF_8);
// Parse JSON and sanitize sensitive fields
JsonNode jsonNode = objectMapper.readTree(body);
sanitizeJsonNode(jsonNode);
// Convert back to JSON string
String sanitized = objectMapper.writeValueAsString(jsonNode);
context.setEntityStream(
new ByteArrayInputStream(
sanitized.getBytes(StandardCharsets.UTF_8)));
return null;
} catch (Exception e) {
LOG.error(”Failed to sanitize request body”, e);
// If JSON parsing fails, return original body
if (originalBytes != null) {
context.setEntityStream(new ByteArrayInputStream(originalBytes));
}
// Don’t throw exception - let the request continue with original body
return null;
}
})
.runSubscriptionOn(Infrastructure.getDefaultWorkerPool())
.replaceWithVoid();
}
private void sanitizeJsonNode(JsonNode node) {
if (node.isObject()) {
ObjectNode objectNode = (ObjectNode) node;
objectNode.fieldNames().forEachRemaining(fieldName -> {
JsonNode fieldValue = objectNode.get(fieldName);
// Check if this is a sensitive field
if (SENSITIVE_FIELDS.contains(fieldName.toLowerCase())) {
objectNode.put(fieldName, “********”);
} else if (fieldValue.isObject() || fieldValue.isArray()) {
// Recursively sanitize nested objects and arrays
sanitizeJsonNode(fieldValue);
}
});
} else if (node.isArray()) {
// Recursively sanitize each element in the array
for (JsonNode arrayElement : node) {
sanitizeJsonNode(arrayElement);
}
}
}
}
The resource will now receive the sanitized payload.
Key Points
Annotation-driven activation: Only sanitizes requests when the target resource method or class is annotated with @Sanitized, allowing selective application across endpoints.
Recursive sanitization: Traverses nested JSON structures (objects and arrays) to sanitize sensitive fields at any depth, ensuring comprehensive data protection.
Non-blocking execution: Uses Mutiny’s Uni and runs on a worker thread pool to maintain the reactive, non-blocking nature of the Quarkus application while performing I/O operations.
Trick B: Injecting Metadata via Headers - Trace ID Injection
Request filters are also ideal for correlation and tracing. A common pattern is to inject a request ID if the client did not send one.
package com.example.filter;
import java.util.UUID;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.server.ServerRequestFilter;
import jakarta.ws.rs.container.ContainerRequestContext;
@Traced
public class TraceIdFilter {
private static final Logger LOG = Logger.getLogger(TraceIdFilter.class);
@ServerRequestFilter
public void trace(ContainerRequestContext context) {
String traceId = context.getHeaderString(”X-Trace-ID”);
if (traceId == null) {
traceId = UUID.randomUUID().toString();
context.getHeaders().add(”X-Trace-ID”, traceId);
}
LOG.infof(”TraceIdFilter LOG: %s”, traceId);
}
}
From this point on:
Resources can read
X-Trace-IDLogs can correlate requests
Downstream services can reuse it
Our Test Resource
Now we need something to intercept. Let’s modify the scaffolded GreetingResource
Create: src/main/java/com/example/GreetingResource.java
package com.example;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.MediaType;
@Path(”/hello”)
public class GreetingResource {
@POST
@Consumes(MediaType.APPLICATION_JSON)
public String create(String json) {
return “Received: “ + json;
}
}This resource simply echoes the payload it receives.
Apply Filters Per Endpoint
Now the payoff. You can mix and match filters at class or method level.
Example: Public API with Audit + Trace
package com.example;
import com.example.filter.Audited;
import com.example.filter.Traced;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.MediaType;
@Path(”/hello”)
public class GreetingResource {
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Audited
@Traced
@Path(”/create”)
public String create(String json) {
return “Received: “ + json;
}
}Example: Sensitive Endpoint with Sanitization
import com.example.filter.Sanitized;
//..
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Sanitized
@Traced
@Path(”/register”)
public String register(String json) {
return “User created: “ + json;
}
Filter Ordering (Important)
If multiple filters touch the body, order matters.
Quarkus REST uses priorities.
Example:
import jakarta.ws.rs.Priorities;
import jakarta.annotation.Priority;
@Priority(Priorities.AUTHENTICATION)
@Sanitized
public class SanitizationFilter { ... }
Rule of thumb:
Sanitization before audit
Trace IDs before logging
Mutation before observation
Be explicit.
When to Split Filters
You should split filters when:
They block vs non-blocking
They mutate vs observe
They apply to different endpoints
They have different failure semantics
Do not split just for aesthetics.
JAX-RS Filters vs Quarkus @ServerRequestFilter
Quarkus supports both standard JAX-RS filters and its own reactive filter model. They look similar, but they serve different runtimes and mental models.
Standard JAX-RS Filters
Classic JAX-RS filters:
Implement
ContainerRequestFilterAre registered via
@ProviderExecute synchronously
Block the executing thread when doing IO
Use
@Priorityor@NameBindingfor ordering and scopingAre typical for RESTEasy Classic and servlet-based runtimes
They work well in traditional, thread-per-request models.
Quarkus @ServerRequestFilter
Quarkus introduces a simpler and more powerful alternative:
Declared using
@ServerRequestFilteron any methodNo interface implementation required
CDI-managed by default
Method signatures are flexible
Can return
void,Uni<Void>, orRestResponseCan receive injected parameters such as
ContainerRequestContextorResourceInfo
These filters are designed for Quarkus’s reactive, non-blocking execution model.
Why @ServerRequestFilter Is Usually the Right Choice
Compared to classic JAX-RS filters, Quarkus filters:
Reduce boilerplate
Support non-blocking, async processing
Integrate cleanly with CDI
Allow fine-grained inspection of the matched resource
Fit naturally into reactive pipelines
In short, @ServerRequestFilter is the Quarkus-native way to intercept requests.
Use classic JAX-RS filters only when you need portability across non-Quarkus runtimes.
Verify the Behavior
Start the application:
quarkus devSend a Request to create (Audit + Trace)
curl -X POST http://localhost:8080/hello/create \
-H "Content-Type: application/json" \
-d '{"item": "Laptop", "price": 1000}' | jqExpected Console Output
2025-12-13 07:24:20,970 INFO [com.example.filter.RequestAuditFilter] (executor-thread-6) AUDIT LOG:
{
“item” : “Laptop”,
“price” : 1000
}
2025-12-13 07:24:20,971 INFO [com.example.filter.TraceIdFilter] (executor-thread-5) TraceIdFilter LOG: b1a0ddb1-df04-4962-a675-4a1c01531a35Expected HTTP Response
{
“message”: “Received”,
“data”: {
“item”: “Laptop”,
“price”: 1000
}
}Send a Request to register (Sanitization + Trace)
curl -X POST http://localhost:8080/hello/register \
-H “Content-Type: application/json” \
-d ‘{”user”: {”username”: “john”, “password”: “secret123”}, “item”: “Laptop”, “price”: 1000}’ | jqExpected Console Output
2025-12-13 07:23:04,053 INFO [com.example.filter.TraceIdFilter] (executor-thread-3) TraceIdFilter LOG: 5d49c278-e124-47bd-bca2-76cf4b1830b6
2025-12-13 07:23:04,054 INFO [com.example.filter.SanitizationFilter] (executor-thread-4) SanitizationFilter LOGExpected HTTP Response
{
“message”: “User created”,
“data”: {
“user”: {
“username”: “john”,
“password”: “********”
},
“item”: “Laptop”,
“price”: 1000
}
}If you enabled sanitization, the response will reflect the modified payload.
Production Notes
This pattern is powerful but not free.
Do not inspect large payloads
Do not block for external systems inside the filter
Prefer async handoff for audit storage
Be explicit about which requests you inspect
Used correctly, request filters are one of Quarkus’ sharpest tools.
Handle request inspection deliberately.
It is infrastructure code. Treat it with respect.





A while ago I created a similar project that used JAX-RS filters for request and response tracking, adding the X-Correlation-ID header, using the Quarkus eventBus to then decide where to store the requests. The project on GitHub https://github.com/amusarra/eventbus-logging-filter-jaxrs