DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

OrderHub Day 24: authenticating callers that are already inside the network

For the first twenty-three days of building OrderHub — a Spring Boot microservices system I'm growing from zero — the services trusted each other completely. Once order-service could resolve inventory-service through Eureka and reach it over the network, it could read stock and reserve units, no questions asked. That "inside the perimeter, therefore safe" assumption is exactly what zero-trust rejects, and Day 24 is where I tear it out. Reachable is not the same as allowed.

Why the network is not a trust boundary

Inside a real cluster, a lot of things can reach a service that assumed only friends could: a compromised pod, a misconfigured cron job, a leaked internal URL, a curious developer on the VPN. So every service should authenticate its callers — even the internal ones. The lightest scheme, and the right first step before mTLS or signed JWTs, is a shared service token: one secret that trusted services know, presented on every request in a header, checked by the receiver.

# BEFORE (Day 23): anything that can reach the service can use it
GET /api/inventory/KEYBOARD-001                       -> 200

# AFTER (Day 24): the caller must prove who it is
GET /api/inventory/KEYBOARD-001
X-Service-Token: <shared secret>                      -> 200
GET /api/inventory/KEYBOARD-001                       -> 401  (no token)
Enter fullscreen mode Exit fullscreen mode

The gate: a servlet filter at the very edge

Authentication is a cross-cutting concern that must run before Spring MVC routes to any controller, so an unauthenticated call is rejected as cheaply as possible and never touches the stock logic. That is a servlet filter, not a controller check. OncePerRequestFilter guarantees one run per request, and shouldNotFilter() narrows the gate to just the API surface:

public class ServiceTokenAuthFilter extends OncePerRequestFilter {
  @Override protected boolean shouldNotFilter(HttpServletRequest req) {
    if (!props.enabled()) return true;                  // master switch
    String path = req.getRequestURI();
    return path == null || !path.startsWith("/api/");   // only guard /api/**
  }
  @Override protected void doFilterInternal(HttpServletRequest req,
        HttpServletResponse res, FilterChain chain) throws ... {
    if (isValid(req.getHeader(props.header()))) { chain.doFilter(req, res); return; }
    writeUnauthorized(res, reason);                     // 401, before any controller
  }
}
Enter fullscreen mode Exit fullscreen mode

Two subtle security bugs, both avoided

Comparing the token looks trivial, but there are two traps. First, a.equals(b) on a secret leaks information through timingString.equals short-circuits on the first differing byte, so an attacker measuring response times can recover the token one character at a time. Use constant-time comparison. Second, never log the presented token, even a wrong one — a bad token can still be a real secret pasted from somewhere else.

private boolean isValid(String presented) {
  if (!StringUtils.hasText(presented) || !StringUtils.hasText(props.token()))
    return false;
  byte[] a = presented.getBytes(UTF_8);
  byte[] b = props.token().getBytes(UTF_8);
  return MessageDigest.isEqual(a, b);   // CONSTANT-TIME — no early-exit timing leak
}
// log the reason + caller IP, NEVER the token value.
Enter fullscreen mode Exit fullscreen mode

The filter is wired up with a FilterRegistrationBean pinned to /api/* at HIGHEST_PRECEDENCE — explicit rather than @Component, which both lets me set the URL space and order, and keeps the filter out of @WebMvcTest slices so the controller contract tests keep passing while the gate is exercised by its own focused test.

The caller side: a Feign interceptor stamps the token

A gate on the callee is only half the story — the caller must present the token on every outbound call. In order-service that is a Feign RequestInterceptor, declared as a @Bean so it applies globally to every Feign client:

@Component
public class ServiceTokenFeignInterceptor implements RequestInterceptor {
  @Override public void apply(RequestTemplate template) {
    if (!StringUtils.hasText(props.token())) return;   // nothing configured -> add nothing
    template.removeHeader(props.header());              // exactly one clean value
    template.header(props.header(), props.token());     // stamp X-Service-Token
  }
}
// call site is unchanged: inventoryClient.reserve(sku, req);  // token attached automatically
Enter fullscreen mode Exit fullscreen mode

The reserve-stock path and the stock reads now authenticate with zero change at the call site.

The token itself never gets hardcoded

The token is a secret, so what lives in git is a placeholder, not a value. Spring resolves ${SERVICE_TOKEN} from the environment at boot; a clearly-labelled non-secret default lets local dev and CI boot without the real token. Both services read the same ${SERVICE_TOKEN} var, so they agree on the secret without either one hardcoding it — rotate by changing one env var and restarting, no code change in any service.

Health stays open on purpose

The one operational detail that matters most: only /api/* is guarded, and /actuator/health must not be. The client-side load balancer and Eureka probe that endpoint on every instance to decide who is live. If health required the token, a single token typo — or a rotation that reached the caller before the callee — would make every instance look "down," the balancer would pull them all out of rotation, and an auth misconfiguration would become a total outage. Guard the real business API; leave the machinery that keeps the service in service open.

A shared token is a solid first gate; its honest limits (one secret to rotate everywhere, no per-caller identity, no expiry) point at the production upgrades — mTLS, JWT client-credentials, a service mesh — all of which share today's core principle: authenticate the caller before doing any work.

Try the interactive gate — pick a valid, wrong, or missing token and watch the 401 land at the edge:
https://dev48v.infy.uk/orderhub.php

Code: https://github.com/dev48v/order-hub-from-zero

Top comments (0)