Let the Logs Speak: Building an Audit Trail with Quarkus and JSON Logging
Track every user action in your Java APIs with structured logs, OIDC identity, and zero external tools. Compliance-friendly and beginner-proof.
Enterprise APIs must do more than just work. They must prove they worked correctly. In regulated environments like healthcare (HIPAA) and finance (SOX), it’s not enough to process transactions. You need to show who accessed what, when, and how. That’s where audit trails come in.
In this hands-on tutorial, we’ll build an simple automatic audit logging system in Quarkus using:
quarkus-oidc
for user identity,quarkus-logging-json
for structured logs,quarkus-rest-jackson
for your REST APIs, anda custom JAX-RS filter to wire it all together.
We use Keycloack Dev Services as external OIDC provider to authenticate users. As close as possible to reality but still easy and pre-configured for a quick getting started experience.
Let’s build this from scratch.
Create the Quarkus Project
Open a terminal and run:
quarkus create app com.example:audit-app \
--extension=quarkus-rest-jackson,quarkus-oidc,io.quarkus:quarkus-logging-json
cd audit-app
This sets up your project with:
REST + Jackson for JSON APIs,
OIDC for identity propagation,
Keycloak Dev Services, and
JSON logging for structured, machine-readable audit records.
If you want to, visit my Github repository, leave a star, and clone the ready-made project to take a quick look.
Configure Authenticated Users
Configure OIDC in src/main/resources/application.properties
:
# Fake OIDC server configuration for dev
quarkus.oidc.client-id=backend-service
quarkus.oidc.credentials.secret=secret
Create a Secure API Endpoint
Create a new class:
src/main/java/com/example/FinancialResource.java
package com.example;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
import jakarta.inject.Inject;
import org.eclipse.microprofile.jwt.JsonWebToken;
@Path("/api/transactions")
public class FinancialResource {
@Inject
JsonWebToken jwt;
@POST
@RolesAllowed("auditor")
public Response createTransaction(String transactionData) {
String user = jwt.getName();
// Process the transaction here...
return Response.ok("Transaction processed for user: " + user).build();
}
}
This endpoint:
Requires the caller to have the
auditor
role.Extracts the username from the JWT.
Simulates processing a financial transaction.
Implement the Audit Trail Filter
We’ll create a JAX-RS filter that logs request and response data for every call to /api/transactions
.
First, define a POJO to hold audit metadata.
src/main/java/com/example/AuditRecord.java
package com.example;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AuditRecord {
public Instant timestamp;
public String principal;
public String clientIp;
public String httpMethod;
public String resourcePath;
public int httpStatus;
}
Now implement the request/response filter.
src/main/java/com/example/AuditFilter.java
package com.example;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.vertx.core.http.HttpServerRequest;
import jakarta.inject.Inject;
import jakarta.ws.rs.container.*;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.logging.Logger;
import java.time.Instant;
@Provider
public class AuditFilter implements ContainerRequestFilter, ContainerResponseFilter {
private static final Logger AUDIT_LOGGER = Logger.getLogger("AuditLogger");
private static final String AUDIT_RECORD_PROPERTY = "auditRecord";
@Inject
JsonWebToken jwt;
@Inject
HttpServerRequest request;
@Inject
ObjectMapper objectMapper;
@Override
public void filter(ContainerRequestContext requestContext) {
if (requestContext.getUriInfo().getPath().contains("transactions")) {
AuditRecord record = new AuditRecord();
record.timestamp = Instant.now();
record.httpMethod = requestContext.getMethod();
record.resourcePath = requestContext.getUriInfo().getPath();
record.clientIp = request.remoteAddress().toString();
record.principal = jwt.getRawToken() != null ? jwt.getName() : "anonymous";
requestContext.setProperty(AUDIT_RECORD_PROPERTY, record);
}
}
@Override
public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) {
AuditRecord record = (AuditRecord) requestContext.getProperty(AUDIT_RECORD_PROPERTY);
if (record != null) {
record.httpStatus = responseContext.getStatus();
try {
AUDIT_LOGGER.info(objectMapper.writeValueAsString(record));
} catch (Exception e) {
// Fail silently in audit logger
}
}
}
}
This filter captures:
The timestamp of the request,
User identity,
Client IP,
HTTP method,
Endpoint path,
HTTP response status.
The result is logged as a clean JSON entry.
Configure Structured Logging
Add this to application.properties
to register a clean, dedicated JSON logger:
# Disable JSON logging globally (even though quarkus-logging-json dependency is present)
quarkus.log.console.json=false
# Configure root console handler to use standard format (not JSON)
quarkus.log.console.enable=true
quarkus.log.console.format=%d{HH:mm:ss} %-5p [%c{2.}] (%t) %s%e%n
quarkus.log.console.level=INFO
# Configure AuditLogger category to use separate handler
quarkus.log.category."AuditLogger".level=INFO
quarkus.log.category."AuditLogger".use-parent-handlers=false
quarkus.log.category."AuditLogger".handlers=AUDIT
# Define the AUDIT handler with simple format (JSON conversion happens in code)
quarkus.log.handler.console."AUDIT".enable=true
quarkus.log.handler.console."AUDIT".format=%m%n
quarkus.log.handler.console."AUDIT".level=INFO
This keeps your audit logs separate, machine-readable, and easy to ship to Elasticsearch, Loki, or any structured log collector.
Run and Test
Launch the app in dev mode:
quarkus dev
Use Quarkus Dev UI to generate a token for testing.
Go to the Extensions and click on “OpenID Connect” > “Keycloak Provider”.
Click “Log into Single Page Application”
Enter “alice” as user and “alice” as password
Copy the “Access Token” to the clipboard
Use it as
curl -i -X POST \
-H "Authorization: Bearer YOUR_ACCESSTOKEN_HERE" \
-d "Sample transaction data" \
http://localhost:8080/api/transactions
Check your Quarkus console log. You will see something like:
{"timestamp":"2025-07-18T18:35:02.123456Z","principal":"alice","clientIp":"/127.0.0.1:57324","httpMethod":"POST","resourcePath":"/api/transactions","httpStatus":200}
That’s your audit trail. It’s clean and ready to be persisted in a tamper prove way.
You can also explore using Quarkus SecurityIdentity instead of raw JWT parsing, or enhance the filter with @Priority
to control execution order with other filters.
Proof-of-Concept, Not Panopticon
Before anyone gets too excited, or worried, let’s be clear: this tutorial is a learning-focused Proof of Concept (PoC). It’s meant to teach you the fundamentals of audit logging in Quarkus, not to replace your enterprise SIEM (Security Information and Event Management) system or satisfy your company’s CISO.
Yes, we log who did what, when, and how. But we’re doing it all in-memory, with local dev tokens, and logging to the console like it's 1999. That’s fine for now. This tutorial is like the Hello World of audit trails: useful, minimal, and most importantly, understandable.
Security and compliance aren’t just hard. They’re absurdly hard. The moment you think you’ve logged everything, someone will ask, “But what about admin users accessing logs from Kubernetes shell sessions?” That’s not in scope here.
What you should take away from this PoC:
You now know how to capture request metadata and user identity in Quarkus.
You understand how to hook into JAX-RS filters to build cross-cutting behavior like logging.
You’re producing structured logs, not random console output.
That puts you well ahead of many backend systems in production today.
From here, the real fun begins:
Stream audit logs to Kafka, Elastic, or Loki.
Store them in an immutable, append-only database.
Add correlation IDs and session tracking.
Use MDC to thread user context into all your logs.
Introduce alerting for abnormal access patterns.
Treat this PoC like a compass, not a map. It points you in the right direction, but where you go from here? That’s up to you.
Security is hard. Logging is hard. Making them work together while keeping your app fast? That’s borderline wizardry.
But guess what? You just learned your first spell.