You put a lot of work into authentication.
A gateway validates the Keycloak JWT, maps realm roles to authorities, checks that the caller is allowed.
By the time a request reaches your service, you know exactly who is calling.
Then the request crosses into async land, and all of that evaporates.
This is the story of the point where identity quietly disappears in an event-driven system, why the dead-letter queue is the worst possible place for it to disappear, and how I made the acting user as durable and replay-safe as the event itself.
The flow everyone believes is fine The platform is a set of Spring Boot services: an API gateway in front, a on MySQL, a on PostgreSQL, and Kafka carrying events between them.
A user is created, an event is published, a notification is sent.
Authentication is handled at the edge.
The gateway is an OAuth2 Resource Server; it validates the token once and propagates the caller's identity downstream as headers: One detail here matters more than it looks.
The headers are always overwritten, even when a claim is absent.
If a client tries to inject on the inbound request, the gateway stomps it with the validated value (or empty).
Downstream trust in those headers is only safe because the perimeter guarantees they cannot be forged.
Miss that, and you've built an impersonation API.
So far, so good.
The synchronous hop carries identity.
The problem starts one line later.
The hidden failure: the thread boundary I don't publish to Kafka inside the request.
I use the transactional outbox pattern: the request persists the user and an outbox row in one local transaction, and a separate scheduled poller publishes to Kafka afterward. (Why: a direct publish inside the request can commit the DB row and then lose the event if the broker call fails — the dual-write problem.
The outbox closes that gap.) That decoupling is correct for delivery.
It is also exactly where identity dies.
The outbox publisher runs on a scheduled thread, not the request thread. , , — every ambient place you might have stashed "who is calling" — are all empty by the time the poller runs.
There is no request.
There is no token.
There is nothing to read.
So the event goes out anonymous.
The consumer logs anonymous.
The notification is written anonymous.
And when an event exhausts its retries and lands in the dead-letter queue — the one moment you will urgently want to know who triggered it — the dead-letter row is anonymous too.
Provenance vanishes at precisely the point forensics begins.
The naive fixes (and why each one fails) The tempting answers all fail at the same boundary: "Just log the username in the controller." You can — but the log line you care about is the publish, which happens later, on another thread, after the HTTP response has already returned.
The controller log tells you a request arrived; it can't attribute the event that failed twenty seconds later. "Stash it in MDC / a ThreadLocal and read it in the publisher." The publisher isn't on your thread.
MDC is thread-scoped; the scheduled poller starts with a clean, empty context.
You'll read every time.
Worse, under Virtual Threads with pooled carriers, a stale ThreadLocal is a correctness hazard — you can leak one request's identity onto another's event. "Re-read the JWT in the publisher." There is no request in scope and no token to re-validate.
The authentication event is long over.
Every naive fix assumes identity lives in ambient thread state.
Across an async boundary, ambient state is exactly what you don't have.
The production implementation: persist identity with the event If the publish is decoupled from the request in time and thread, then identity has to travel the same way the event does — as data, not as ambient context.
So I capture the actor on the request thread and persist it onto the outbox row, inside the same transaction as the entity write: Now identity is as durable as the event.
It survives a crash, a restart, a redeploy — because it's a committed row,