Stop Leaking Bearer Tokens in Quarkus Client Logs
A small ConnectHub sample shows the default mask list, the easy footgun, and the test setup that catches mistakes.
Incident debugging is when people most often crank logging up. Outbound REST clients are easy to forget because the traffic is not the browser session you already worry about: it is server-to-server calls with bearer tokens, session cookies, and bespoke signing headers. Turn request-response logging on for the client without lining up headers first, and those values sit next to stack traces in the same log stream your support search tools can read.
Quarkus wires the default REST Client logger straight into application.properties, and the REST Client guide documents the four settings that matter here: quarkus.rest-client.logging.scope, body-limit, masked-headers, and per-client overrides under quarkus.rest-client."<config-key>".logging.*. In this walkthrough we build a tiny integration façade called ConnectHub, turn logging on on purpose, break masked-headers once on purpose, then put global and per-client lists back in a sane shape. This module uses Quarkus 3.35.1, and I still re-check masked output after upgrades because the exact replacement token in log lines can move between releases. CI should grep for the secret substrings themselves, not for one specific mask string.
What we build
You end up with one Quarkus application that:
exposes ingress HTTP under
/connect/hooksto trigger outbound calls;hosts simulated downstream JAX-RS resources on
/simulate/paymentsand/simulate/notifyin the same JVM;declares two MicroProfile REST clients (
payments-api,notify-api) that call back into127.0.0.1:${quarkus.http.port}with different sensitive headers;enables
request-responseclient logging with bounded bodies;configures
masked-headersglobally, then shows a per-client override where the notify client tightens masking compared with the global list.
The layout stays boring on purpose: no WireMock, no second process, just the loopback pattern integration services already use when they wrap legacy APIs behind one façade.
Prerequisites
I am assuming you are comfortable running Quarkus from Maven or the Quarkus CLI. The walkthrough matches the module in this directory and assumes:
JDK 21 installed
Quarkus CLI available if you want the exact
quarkus createflow below (Maven alone is enough afterward)Familiarity with MicroProfile REST Client (
@RegisterRestClient,@RestClient)☕️
Project setup
Create the application or start from my Github repository.
quarkus create app dev.connecthub:connecthub-client-masking \
--extension=quarkus-rest-jackson,quarkus-rest-client-jackson,quarkus-smallrye-openapi \
--java=21 \
--no-code--no-code skips the greeting codestart so the package layout matches the listings below.
Extensions:
quarkus-rest-jackson— server-side REST with Jackson (ingress and simulated downstreams).quarkus-rest-client-jackson— declarative REST clients with Jackson and the built-in client logger.quarkus-smallrye-openapi— OpenAPI and Swagger UI, because real integration services almost always expose contract metadata somewhere.
The generated pom.xml keeps the normal Quarkus BOM setup. We do need to add RestAssured though:
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>Under src/main/java, create the package directories implied by each listing (dev/connecthub, dev/connecthub/client, dev/connecthub/simulate).
Demo tokens (fake data only)
Keep secrets out of scattered string literals. The tutorial uses obvious fake values in DemoTokens so grep-based checks stay unambiguous:
package dev.connecthub;
/**
* Fake credentials for tutorial traffic only. Do not reuse in real systems.
*/
public final class DemoTokens {
public static final String BEARER = "leak-test-bearer-wxy789";
public static final String SESSION = "leak-test-session-abc123";
public static final String SIGNATURE = "leak-test-signature-def456";
private DemoTokens() {
}
}Simulated downstreams
Two minimal resources answer GET pings. They stand in for payment and notification APIs you would normally reach over a VPN or mesh route.
package dev.connecthub.simulate;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/simulate/payments")
public class PaymentsEchoResource {
@GET
@Path("/ping")
@Produces(MediaType.TEXT_PLAIN)
public String ping() {
return "payments-ok";
}
}package dev.connecthub.simulate;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/simulate/notify")
public class NotifyEchoResource {
@GET
@Path("/ping")
@Produces(MediaType.TEXT_PLAIN)
public String ping() {
return "notify-ok";
}
REST client interfaces and header filters
Each client gets its own ClientRequestFilter implementation. Register filters with @RegisterProvider on the REST client interface, not as standalone @Provider CDI beans, otherwise JAX-RS can treat them as server-side providers and the result gets confusing fast.
package dev.connecthub.client;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.core.HttpHeaders;
import dev.connecthub.DemoTokens;
public class PaymentsAuthFilter implements ClientRequestFilter {
@Override
public void filter(ClientRequestContext requestContext) {
requestContext.getHeaders().putSingle(HttpHeaders.AUTHORIZATION, "Bearer " + DemoTokens.BEARER);
requestContext.getHeaders().putSingle("X-ConnectHub-Signature", DemoTokens.SIGNATURE);
}
}package dev.connecthub.client;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.core.HttpHeaders;
import dev.connecthub.DemoTokens;
public class NotifyAuthFilter implements ClientRequestFilter {
@Override
public void filter(ClientRequestContext requestContext) {
requestContext.getHeaders().putSingle(HttpHeaders.COOKIE, "session=" + DemoTokens.SESSION);
requestContext.getHeaders().putSingle("X-ConnectHub-Signature", DemoTokens.SIGNATURE);
}
}package dev.connecthub.client;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey = "payments-api")
@RegisterProvider(PaymentsAuthFilter.class)
@Path("/simulate/payments")
public interface PaymentsApiClient {
@GET
@Path("/ping")
@Produces(MediaType.TEXT_PLAIN)
String ping();
}package dev.connecthub.client;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey = "notify-api")
@RegisterProvider(NotifyAuthFilter.class)
@Path("/simulate/notify")
public interface NotifyApiClient {
@GET
@Path("/ping")
@Produces(MediaType.TEXT_PLAIN)
String ping();
}Ingress resource
CDI injection for REST clients requires the @RestClient qualifier from MicroProfile. Without it, Arc sees the client bean type but not your @Inject point.
package dev.connecthub;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
import dev.connecthub.client.NotifyApiClient;
import dev.connecthub.client.PaymentsApiClient;
@Path("/connect/hooks")
public class ConnectHubHooksResource {
private static final Logger LOG = Logger.getLogger(ConnectHubHooksResource.class);
@Inject
@RestClient
PaymentsApiClient paymentsApiClient;
@Inject
@RestClient
NotifyApiClient notifyApiClient;
@GET
@Path("/demo")
@Produces(MediaType.TEXT_PLAIN)
public String runDemo() {
String p = paymentsApiClient.ping();
String n = notifyApiClient.ping();
LOG.infof("ConnectHub demo finished: payments=%s notify=%s", p, n);
return "ok";
}
@GET
@Path("/payments-only")
@Produces(MediaType.TEXT_PLAIN)
public String paymentsOnly() {
return paymentsApiClient.ping();
}
@GET
@Path("/notify-only")
@Produces(MediaType.TEXT_PLAIN)
public String notifyOnly() {
return notifyApiClient.ping();
}
}Configuration
src/main/resources/application.properties wires loopback URLs and logging. ${quarkus.http.port} keeps tests and dev mode honest when the HTTP port moves.
quarkus.application.name=connecthub-client-masking
# Simulated downstreams live in the same JVM; clients call back into this process.
quarkus.rest-client.payments-api.url=http://127.0.0.1:${quarkus.http.port}
quarkus.rest-client.notify-api.url=http://127.0.0.1:${quarkus.http.port}
# Safe defaults for local dev: request/response logging with bounded bodies and masked secrets.
quarkus.rest-client.logging.scope=request-response
quarkus.rest-client.logging.body-limit=80
quarkus.rest-client.logging.masked-headers=Authorization,Cookie,X-ConnectHub-Signature
quarkus.rest-client.logging.scope — request-response logs outbound requests and responses. The guide also documents all (noisier) and none.
quarkus.rest-client.logging.body-limit — caps how many characters of each body get printed. Default is 100 in current docs; here it is set to 80 as a reminder that bodies need an explicit budget when someone turns logging up under pressure.
quarkus.rest-client.logging.masked-headers — case-insensitive header names whose values are replaced in client log lines. The REST Client guide calls out the real footgun here: the default list masks Authorization and Cookie, but the moment you set masked-headers yourself, that default list is replaced. Forget Authorization in your explicit list and the bearer token prints in plain text. The tests below encode that failure on purpose with QuarkusTestProfile overrides.
Per-client keys — for a configKey of notify-api, logging overrides use quarkus.rest-client.notify-api.logging.* (hyphenated keys work in application.properties; quoted "notify-api" form exists for edge cases in the guide’s examples).
What masked output looks like here
The guide describes replacement values in generic terms. On Quarkus 3.35.1 with the client logger, masked header values show up as <hidden> inside the Headers[...] block on each Request: line. Your log shipper may quote that differently.
Tests that prove the failure modes
The profile side is small enough to show:
// LeakyLoggingProfile.java
package dev.connecthub;
import java.util.Map;
import io.quarkus.test.junit.QuarkusTestProfile;
/**
* Overrides global masked headers with a list that omits {@code Authorization} and {@code Cookie},
* which demonstrates the footgun described in the REST Client guide: explicit lists replace defaults.
*/
public class LeakyLoggingProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"quarkus.rest-client.logging.scope", "request-response",
"quarkus.rest-client.logging.body-limit", "80",
"quarkus.rest-client.logging.masked-headers", "X-ConnectHub-Signature");
}
}
// SafeLoggingProfile.java
package dev.connecthub;
import java.util.Map;
import io.quarkus.test.junit.QuarkusTestProfile;
public class SafeLoggingProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"quarkus.rest-client.logging.scope", "request-response",
"quarkus.rest-client.logging.body-limit", "80",
"quarkus.rest-client.logging.masked-headers", "Authorization,Cookie,X-ConnectHub-Signature");
}
}
// MixedMaskingProfile.java
package dev.connecthub;
import java.util.Map;
import io.quarkus.test.junit.QuarkusTestProfile;
/**
* Global masking omits the custom header (so it would leak on clients that inherit only globals).
* The notify REST client adds {@code X-ConnectHub-Signature} back on its own logging config.
*/
public class MixedMaskingProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"quarkus.rest-client.logging.scope", "request-response",
"quarkus.rest-client.logging.body-limit", "80",
"quarkus.rest-client.logging.masked-headers", "Authorization,Cookie",
"quarkus.rest-client.notify-api.logging.masked-headers", "Authorization,Cookie,X-ConnectHub-Signature");
}
}These three profiles define the whole story: a broken global list, a safe global list, and a mixed case where one client tightens masking beyond the global default.
The other piece worth showing is the log capture helper:
package dev.connecthub;
import java.io.StringWriter;
import java.util.logging.Level;
import org.jboss.logmanager.LogContext;
import org.jboss.logmanager.Logger;
import org.jboss.logmanager.formatters.PatternFormatter;
import org.jboss.logmanager.handlers.WriterHandler;
/**
* Captures {@link org.jboss.resteasy.reactive.client.logging.DefaultClientLogger} output for tests.
*/
final class ClientLogCapture implements AutoCloseable {
static final String DEFAULT_CLIENT_LOGGER_CATEGORY = "org.jboss.resteasy.reactive.client.logging.DefaultClientLogger";
private final WriterHandler handler;
private final StringWriter writer;
private final Logger logger;
ClientLogCapture() {
writer = new StringWriter();
handler = new WriterHandler();
handler.setWriter(writer);
handler.setFormatter(new PatternFormatter("%m%n"));
handler.setLevel(Level.ALL);
logger = LogContext.getLogContext().getLogger(DEFAULT_CLIENT_LOGGER_CATEGORY);
logger.addHandler(handler);
if (!logger.isLoggable(Level.INFO)) {
logger.setLevel(Level.INFO);
}
}
String captured() {
handler.flush();
return writer.toString();
}
@Override
public void close() {
logger.removeHandler(handler);
}
}Three @QuarkusTest classes sit on top of that helper:
LeakyLoggingProfilesetsquarkus.rest-client.logging.masked-headerstoX-ConnectHub-Signatureonly. Authorization and Cookie are no longer in the list, so the bearer token is logged literally.LeakyRestClientLoggingTestasserts the leak.SafeLoggingProfilerestoresAuthorization,Cookie,X-ConnectHub-Signature.MaskedRestClientLoggingTestasserts none of the demo token strings appear in captured client logger output.MixedMaskingProfilesets globalmasked-headers=Authorization,Cookie(so a custom signing header would leak for clients that rely on the global list alone) and addsquarkus.rest-client.notify-api.logging.masked-headers=Authorization,Cookie,X-ConnectHub-Signature.PerClientMaskingOverrideTesthits/connect/hooks/payments-onlyand/connect/hooks/notify-onlyand checks the asymmetry.
ClientLogCapture attaches a WriterHandler to org.jboss.resteasy.reactive.client.logging.DefaultClientLogger, which is the logger behind the default REST client traffic logger. Remember to setFormatter(...) on the handler. Skip that and WriterHandler rewards you with a FORMAT_FAILURE NullPointerException the first time the client logs, which is not my favorite way to learn how JBoss LogManager feels about defaults.
Production hardening
Masked headers cover what the REST client logger prints, not every other place a secret might escape. When I touch outbound logging in a service I still walk the rest of the path once:
Distributed traces — HTTP client spans and attributes can repeat routing information or header-derived tags depending on instrumentation, so trace exporters and tail sampling policies belong in the same review pass as log masking.
Body logging —
body-limitreduces volume; it does not make sensitive JSON safe. If downstream responses include PII or tokens in bodies,request-responsescope is the wrong default for that client.Audit trails — compliance-friendly audit logs usually want proof that a call happened, not a verbatim copy of signing material.
Prove it
From the module root:
./mvnw testOn this module, that command runs four @QuarkusTest methods covering the leak, the safe global list, and the per-client override case.
Then run dev mode and watch the console while you curl:
./mvnw quarkus:devcurl -s http://localhost:8080/connect/hooks/demoIn dev you should see Request: lines from org.jboss.resteasy.reactive.client.logging.DefaultClientLogger where Authorization, Cookie, and X-ConnectHub-Signature values are masked, while something boring like User-Agent still prints normally. In the safe test profile, the two requests look like Authorization=<hidden>, Cookie=<hidden>, and X-ConnectHub-Signature=<hidden>. In the intentionally broken profile, the same logger prints Authorization=Bearer leak-test-bearer-wxy789, which is the whole reason this article exists.
Close
ConnectHub stays small on purpose. I wanted something I can paste into a design note as a pattern, not a second product. When outbound REST logging goes on in a real service, I decide up front which headers belong in the global mask list, which configKey blocks need their own logging.masked-headers, and which substrings tests should forbid so a regression shows up as a red build instead of a quiet INFO line in production.


