How I Secured Internal Microservice Calls Without Passing JWTs
How I Secured Internal Microservice Calls Without Passing JWTs
One of the more interesting problems I ran into while building a microservices system was this:
How should one internal service securely call another service on behalf of a user — without forwarding the user's JWT everywhere?
At first, this sounds simple.
But once scheduled jobs, async processing, and service-to-service communication enter the picture, the usual approaches start breaking down quickly.
The Architecture
The request flow looked roughly like this:
Flutter App → API Gateway → Core Service → User Service
The API Gateway was responsible for:
- validating JWTs
- authenticating the request
- extracting the user ID
Once validated, the gateway injected the user identity into downstream requests.
Something like:
X-User-Id: abc123
The problem started when one internal service needed to call another internally.
For example:
Core Service → User Service
The core service needed user-specific data, but:
- it no longer had the original JWT
- the gateway had already consumed it
- scheduled tasks had no HTTP request context at all
That forced an important architectural decision.
The Naive Approach (And Why It's Dangerous)
The most obvious solution looks like this:
headers.set("X-User-Id", userId);
At first glance, it works.
But it creates a major security issue.
Any external caller could simply send:
X-User-Id: admin123
and impersonate another user.
Without proper trust validation, the entire system becomes vulnerable to header spoofing.
Why Forwarding JWTs Wasn't the Right Fit
Another common approach is forwarding JWTs between services.
That works in simple request chains, but it introduced multiple problems in this architecture.
Problem 1 — Scheduled Tasks
Schedulers and async jobs do not have:
- HTTP request context
- incoming JWTs
- request-scoped authentication
So there is nothing to forward.
Problem 2 — Tight Coupling
Forwarding JWTs forces every service to:
- understand JWT structure
- validate tokens
- depend on authentication details
That increases coupling between services unnecessarily.
Problem 3 — RequestContextHolder Issues
Using thread-local request storage works only inside the original request thread.
As soon as execution becomes:
- async
- scheduled
- event-driven
the context disappears.
The Approach I Ended Up Using
Instead of forwarding JWTs internally, I designed a lightweight internal trust protocol.
The idea was simple:
- external authentication happens only at the gateway
- internal services trust requests coming from inside the private network
- internal calls carry explicit identity headers
Caller Side
When one service calls another:
client.get()
.uri("/users/intent")
.header("X-User-Id", userId)
.header("X-Internal-Call", "true")
.retrieve()
.body(...);
Two headers are sent:
| Header | Purpose |
|---|---|
X-User-Id | identity being acted on |
X-Internal-Call | marks trusted internal traffic |
Receiver Side
Inside the receiving service, the authentication filter checks:
String internalCall = request.getHeader("X-Internal-Call");
String internalUserId = request.getHeader("X-User-Id");
If both exist:
if ("true".equals(internalCall)
&& StringUtils.hasText(internalUserId)) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
internalUserId,
null,
Collections.singletonList(
new SimpleGrantedAuthority("ROLE_USER")
)
);
SecurityContextHolder.getContext()
.setAuthentication(authentication);
filterChain.doFilter(request, response);
return;
}
The service directly creates the SecurityContext internally and skips JWT validation completely.
For external requests, the normal JWT flow still applies.
Why This Is Not Spoofable
At first glance, this may look insecure.
Someone could ask:
"What stops external users from sending
X-Internal-Call: true?"
The answer is network boundaries.
The architecture was designed so that:
- the API Gateway is the only public entry point
- internal services are not exposed publicly
- external traffic cannot directly reach downstream services
The gateway:
- validates JWTs
- injects
X-User-Id - strips internal-only headers from external traffic
The trust model becomes:
External → Gateway → Internal Services
not:
External → Internal Services
That distinction is critical.
The internal network itself becomes the trust boundary.
Why This Worked Better
Compared to forwarding JWTs, this approach had several advantages.
| Approach | Drawback |
|---|---|
| Forward JWTs | breaks for schedulers and async flows |
| RequestContextHolder | thread-local, unreliable outside request thread |
| Service account tokens | extra complexity and token management |
| Internal trust headers | simple and works consistently |
The Result
This approach made the system significantly cleaner.
The benefits:
- scheduled jobs could call services safely
- async workflows no longer depended on HTTP context
- services stayed loosely coupled
- authentication remained centralized at the gateway
- internal communication became simpler and faster
Most importantly:
the system stopped depending on request-scoped authentication everywhere.
The Bigger Engineering Lesson
A lot of microservice architectures overcomplicate internal authentication.
Not every service-to-service request needs full end-user JWT propagation.
If:
- your network is private
- your gateway is trusted
- internal services are isolated
then the network boundary itself becomes part of your security model.
That does not mean "trust everything internally."
It means:
- authenticate once at the edge
- validate boundaries properly
- keep internal communication lightweight
Final Thought
Good architecture is often about deciding where complexity should live.
In this case:
- authentication complexity stayed at the gateway
- internal services remained simple
That made the system:
- easier to scale
- easier to debug
- easier to extend into async and scheduled workflows
without turning every internal call into an authentication problem.