Back to Blog
2026-05-12
8 min read

How I Secured Internal Microservice Calls Without Passing JWTs

#Microservices#Security#Spring Boot#JWT#API Gateway#Authentication#Backend Engineering

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:

HeaderPurpose
X-User-Ididentity being acted on
X-Internal-Callmarks 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.

ApproachDrawback
Forward JWTsbreaks for schedulers and async flows
RequestContextHolderthread-local, unreliable outside request thread
Service account tokensextra complexity and token management
Internal trust headerssimple 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.