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)
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
}
}
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 timing — String.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.
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
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
Top comments (0)