Securing Spring Boot Microservices: Token Relay, Service-to-Service JWT and mTLS
Four real processes on Spring Boot 4.1.1 and Spring Security 7.1.1, and one uncomfortable finding: by default a resource server accepts any structurally valid, unexpired token from its issuer, including one minted for a completely different service. Relay, client credentials and RFC 8693 token exchange compared by transcript; what Spring Cloud Gateway’s TokenRelay actually relays; and why terminating mTLS in a sidecar takes certificate-bound access tokens off the table.
Two services, one token, and a question that turns out to have three answers and one uncomfortable finding. The question is: when service A calls service B on behalf of a user, whose identity should arrive at B, and what does B actually check about it?
The uncomfortable finding is the second half. In a default Spring Boot resource server, B checks the signature and the expiry, and that is very nearly all. A token minted for a completely different service, by the same issuer, is accepted with HTTP 200 — and that is the normal shape of an estate where several services trust one authorization server.
Everything below was produced by running the companion module: a real Spring Authorization Server, a Spring Cloud Gateway, and two resource servers, with every token minted by that authorization server and every claim in this article read off a token that actually existed.
Versions. JDK 25 (Temurin 25.0.4.1+1) · Spring Boot 4.1.1 · Spring Framework 7.0.9 · Spring Security 7.1.1 (resource server, OAuth2 client and authorization server) · Spring Cloud 2025.1.3 (gateway 5.0.3) · Tomcat 11.0.24. Read from repo1.maven.org/…/maven-metadata.xml.
Note one mismatch that matters: spring-cloud-build 5.0.3 declares <spring-boot.version>4.0.8</spring-boot.version>. Spring Cloud is a separate release train with its own Boot baseline. This module runs gateway 5.0.3 under Boot 4.1.1 and it works, but that is a combination nobody tested, and it is why a Boot upgrade can be blocked by a gateway.
Getting a user token without a browser is worth doing once by hand, because the “browser flow” is four HTTP requests and a cookie jar. user-token.sh does all of it in curl: request the code unauthenticated, scrape _csrf off the login page, post the credentials, follow the saved request, redeem the code with the PKCE verifier. What comes out:
sub is the human. scope is what the human consented to. aud names the service the token was minted for — and it is worth saying plainly that Spring Authorization Server does not put that claim there for you. It comes from an OAuth2TokenCustomizer in the companion module. Remember that third one; it comes back later and it is the point of this article.
Three ways to get a token for the next hop
The edge service holds that token and needs to call downstream. Five endpoints, one request each, and the same table of results:
Endpoint
sub downstream sees
scope downstream sees
/edge/naive
— (401)
—
/edge/relay
alice
[orders.write, orders.read]
/edge/client-credentials
edge-service
[orders.read]
/edge/exchange
alice
[orders.read]
/edge/relay-async
— (401)
—
Relay forwards the token you were given, unchanged. Ten lines, no dependency on the OAuth2 client machinery:
Downstream sees alice, which is what an audit log wants. It also sees orders.write, which downstream had no business receiving: the token was minted for the request the user made at the edge, and every service it is relayed to gets the union of everything the user consented to. One compromised service holds a credential good against all of them for the rest of the token’s lifetime.
Client credentials has the service authenticate as itself:
Downstream now sees sub: edge-service and exactly orders.read. The scope problem is solved and the user is gone. Downstream’s log says a service called it, and answering “who ordered this?” means correlating two logs by a request id you hope somebody propagated.
Token exchange (RFC 8693) trades the user’s token for one scoped to the next hop, keeping the user:
sub: alice, scope: [orders.read]. Both properties, from a grant type that has existed since 2020 and that almost nobody reaches for.
The catch with token exchange is wiring, not concept.OAuth2AuthorizedClientProviderBuilder‘s defaults do not include token exchange, so the provider has to be added explicitly:
Leave it out and the manager returns null rather than raising anything. The interceptor then sends the request with no Authorization header at all, and you get a 401 that looks like a downstream problem and is a wiring problem.
The thread that loses the token
/edge/relay-async does exactly what /edge/relay does, on a virtual thread from an executor. It returns 401.
SecurityContextHolder is a ThreadLocal. The interceptor reads it; the task runs on a thread that never had a SecurityContext written to it; the instanceof fails; the header is never set. Nothing throws, nothing is logged, and the failure surfaces one process away as an authentication error.
Any relay built on SecurityContextHolder inherits every failure mode in Spring Security Context Propagation — @Async, executors, schedulers, reactive boundaries. The cures are the same: DelegatingSecurityContextExecutorService, ContextPropagatingTaskDecorator, or capturing the token value on the request thread and passing it as a parameter.
A relay built on an explicit token parameter inherits none of them, at the cost of a parameter on every method between the controller and the HTTP client. That trade is worth making more often than it is made.
RestClient interceptors, and the manager that decides everything
OAuth2ClientHttpRequestInterceptor lives in org.springframework.security.oauth2.client.web.client. Wiring it is one line, and choosing the registration per call is one more:
clientRegistrationId(..) is a static method on RequestAttributeClientRegistrationIdResolver. Without it the default resolver finds nothing and the request goes out unauthenticated.
The choice that decides whether any of this works off a request thread is which manager you give it:
For service-to-service calls there is no end user whose authorization is being stored per session, so the second is right — and it is the one that still works from a scheduled task, a message listener or an @Async method. Getting it wrong produces ClientAuthorizationRequiredException or a silent null in a context with no request, which reads like an OAuth problem and is a bean problem.
Worth knowing about caching: the manager stores the authorized client and reuses it until it is within the clock skew of expiry, so a client_credentials registration does not hit the token endpoint per request. What it does do is re-request synchronously inside whichever call happens to be first after expiry — worth remembering when a latency percentile spikes on a period matching your token lifetime.
And a distinction worth keeping straight: OAuth2ClientHttpRequestInterceptorobtains a token under a client registration. A hand-rolled relay interceptor forwards a token already in hand. There is no client registration for “the caller’s token” and there should not be. They are different things, and only one of them carries the ThreadLocal dependency.
What your resource server does not check
This is the section to read if you read only one.
reporting-service is a client registered on the same authorization server, with nothing to do with the downstream service. Its access tokens carry aud: reporting-api. Send one to the downstream service, whose audience is downstream-api:
Two hundred. Here is why. JwtValidators.createDefault() is a DelegatingOAuth2TokenValidator over three validators — read back by reflection in the companion module’s test suite rather than taken from the disassembly, because the disassembly was one short:
JwtTypeValidator
JwtTimestampValidator
X509CertificateThumbprintValidator
Structure, expiry, and certificate binding. No issuer. No audience. No scope. Setting spring.security.oauth2.resourceserver.jwt.issuer-uri adds a JwtIssuerValidator, so the issuer is covered. Nothing anywhere adds an audience check unless you write it.
The practical consequence. In an estate where several services trust one authorization server — which is the normal shape — any token from that issuer is a valid credential at every service in it. A token a partner integration obtained for the reporting API works against the payments API. Scope may or may not save you: in the transcript above, scope: orders.read did not, because the two services happened to share a scope name.
The fix is two lines:
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
new JwtAudienceValidator("downstream-api")));
JwtAudienceValidator is a public class in Spring Security 7.1. With it, the demonstration above becomes The aud claim is not valid.
The heavier option is the RFC 9068 profile, which is stricter and comes with two surprises:
Surprise one: Spring Authorization Server does not emit an RFC 9068 token. Out of the box its access tokens carry typ: JWT in the header and no client_id claim. createAtJwtValidator() requires typ, exp, sub, iat, jti, iss, audandclient_id, so point it at an unmodified authorization server and every token is rejected. Both gaps are one line in an OAuth2TokenCustomizer:
Surprise two: making the token compliant breaks every resource server that was not updated.NimbusJwtDecoder‘s default JOSE type verifier accepts JWT and an absent typ, and nothing else. The moment the authorization server starts typing tokens at+jwt, every service still using Boot’s auto-configured decoder answers:
WWW-Authenticate: Bearer error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt:
the given typ value needs to be one of [JWT]"
The companion module’s 05-strict-validation.txt has the downstream service accepting the new tokens and the edge service, one hop away and not updated, rejecting them. The message mentions neither RFC 9068 nor the authorization server. The receiving-side fix:
NimbusJwtDecoder.withJwkSetUri(jwks)
.jwtProcessorCustomizer((processor) -> processor.setJWSTypeVerifier(
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"),
JOSEObjectType.JWT, null)))
.build();
Roll that out before you change the authorization server, not after.
Two components, almost the same sentence, opposite meanings. Nimbus’s DefaultJOSEObjectTypeVerifier says the given typ value needs to be one of [JWT]. Spring’s JwtTypeValidator, under the at+jwt profile, says the given typ value needs to be one of [at+jwt, application/at+jwt]. The first means your authorization server is too modern for this decoder. The second means it is not modern enough for this validator.
And one thing that is on by default and deserves more attention: X509CertificateThumbprintValidator is in the default set. If a token carries a cnf claim with x5t#S256 — a certificate-bound access token, RFC 8705 — Spring Security already checks it against the client certificate on the TLS connection, with no configuration at all. That is the strongest available defence against a stolen bearer token, it costs nothing on the resource server, and it needs mTLS to be terminated in the application. Hold that thought.
What TokenRelay actually relays
Spring Cloud Gateway Server MVC ships a filter that sounds like it solves the whole problem:
filters:
- TokenRelay=
It is TokenRelayFilterFunctions, with two forms — tokenRelay() and tokenRelay(String clientRegistrationId). The documentation is precise about what it does and it is not what the name suggests to most readers: it takes the access token of the currently authenticated user, the one obtained by oauth2Login(), or of the named client registration, and puts it in the Authorization header of the proxied request.
It does not mean “forward the incoming bearer token”. If the gateway never performed a login, there is nothing to forward from.
The companion module defines two routes to the same destination differing only in that filter:
/edge/relay (TokenRelay=) → 200, sub: alice
/norelay/x (no TokenRelay) → 200, sub: alice
Identical. The token arrived because the gateway proxied the Authorization header the caller sent, which it would have done anyway. In a pass-through gateway, TokenRelay contributes nothing.
Where it earns its place is the other shape: a gateway that terminates a browser session with oauth2Login(), keeps the tokens server-side, hands the browser nothing but a session cookie, and attaches a token on the way through. That is the backend-for-frontend pattern, and it is a genuinely good answer to “where do I keep the token in a SPA”, because the answer is “not in the SPA” — which sidesteps most of the CORS and SameSite problem as well.
The 401 that has nothing to do with OAuth. Before the gateway had a SecurityFilterChain bean, every call through it came back 401 WWW-Authenticate: Basic — a perfectly valid bearer token, rejected by a gateway that had never been told to expect one. Spring Boot applies a default chain, HTTP Basic and form login over every path, to any application with Spring Security on the classpath and no SecurityFilterChain bean. A gateway is an application. WWW-Authenticate: Basic in front of a token-based estate always means this.
Three shapes, and what each implies:
Shape
Gateway does
Downstream sees
Pass-through
Routes; validates nothing
The caller’s token; every service validates it
Edge validation
Validates, strips, adds its own identity
The gateway’s identity
Backend-for-frontend
oauth2Login(), holds tokens, TokenRelay
The user’s token, obtained by the gateway
Pass-through is the default and is fine while every service validates properly — and the previous section is about how often that assumption is wrong. Edge validation is the one that tempts teams into “the gateway checked it, so we can trust this header”, which is the next section’s failure mode wearing a different hat.
Where you terminate mTLS
The companion module generates a throwaway CA and two client certificates with identical subjects and different issuers:
subject=CN = edge-service, OU = payments issuer=CN = Internal Mesh CA
subject=CN = edge-service, OU = payments issuer=CN = Some Other CA
Identity under mTLS is not the subject. It is the subject plus the fact that a CA in the trust store vouched for it. In-application mTLS on Boot 4 is an SSL bundle and one DSL call:
A good certificate produces a PreAuthenticatedAuthenticationToken with the CN as the principal and — in Spring Security 7 — a FACTOR_X509 authority alongside the roles, sibling of the FACTOR_BEARER a JWT produces and the FACTOR_PASSWORD Basic produces. Any assertion using containsExactly on authorities will fail on it.
Three things the transcript shows that a diagram does not:
The rogue certificate produces no HTTP status at all.curl exits 56. There is no response line, no 401, no 403, and nothing in the application log at INFO. The handshake failed. Your application metrics show nothing, because from the application’s point of view nothing happened; debugging means -Djavax.net.debug=ssl:handshake or the load balancer’s own logs.
client-auth: need is a property of the connector, not of a path. A permitAll() endpoint on the same port fails exactly the same way without a certificate. You cannot expose a public health endpoint on an mTLS-only connector; that needs a second connector, or want plus an explicit rule that treats an absent certificate as anonymous.
Header-based identity is verified by nothing. The last call in the transcript presents a valid certificate for edge-service and a header claiming to be payments-service, and the endpoint reports payments-service.
That last one is what mesh mTLS looks like from inside the application. A sidecar terminates TLS; the application receives plain HTTP on localhost; the peer identity arrives as a header — X-Forwarded-Client-Cert in Envoy, carrying the SPIFFE URI SAN. The trade:
Mesh
In-application
Certificate lifecycle
Handled, rotated automatically
Yours: issuance, rotation, expiry alerts
Application code
None
An SSL bundle plus x509(..)
Identity in the app
A header
A verified X509Certificate
Certificate-bound tokens (RFC 8705)
No
Yes
Failure mode
Anything bypassing the sidecar can spoof the header
Handshake failure, no HTTP status
The row that decides it is the RFC 8705 one. Certificate-bound access tokens — the cnf/x5t#S256 claim that X509CertificateThumbprintValidator already checks by default — bind a token to the TLS connection it was issued for, so a stolen token is useless without the private key. That binding requires the application to see the client certificate. Terminate mTLS in a sidecar and the strongest available defence against token theft is off the table.
The mesh is still the right answer for most estates. Just make the choice knowingly, and know which services you would rather exclude from it.
If you do take identity from a header, the header must be stripped at ingress on every path into the pod, and the pod must not be reachable except through the proxy. Both are infrastructure guarantees that no amount of application code can verify.
Choosing
Situation
Use
One hop, same team, same trust boundary, audit needs the user
Relay
A job with no user: scheduler, message listener, reconciliation
Client credentials
The next hop is another team’s service, and audit needs the user
Token exchange
The next hop is outside your organisation
Client credentials, with a token minted for them
The honest default is relay inside a boundary, exchange across one. Client credentials is the right answer more often than it is used for work with no user attached, and the wrong answer whenever somebody will later ask “who did this?”.
And the question that should come first: do you need any of it? Two Spring Boot applications in one VPC, calling each other, with no multi-tenancy and no external partner, do not need an authorization server, a token exchange grant or a mesh. Network-level isolation plus a shared secret is a defensible design, and it is a design you can reason about at 3am.
The machinery in this article earns its keep when there are enough services, enough teams, or enough regulatory pressure that “who called this, on whose behalf, with what permission” has to be answerable from a log rather than from memory. Below that threshold it is cost with no benefit, and the cost is paid by whoever is on call.
A checklist, short on purpose:
Every resource server validates audience, not just signature and expiry.
No relay reads SecurityContextHolder from a thread the request did not create.
Every service has a SecurityFilterChain bean, so nothing falls back to Boot’s Basic default.
Client registrations name token-uri/jwk-set-uri rather than issuer-uri, unless you want a startup-ordering dependency — issuer-uri makes the client fetch the discovery document during context refresh, and a client that starts before its authorization server dies with ResourceAccessException: … Connection refused.
If identity arrives in a header, something upstream strips that header from every external request, and you can name the component that does it.
Token lifetimes are short enough that a gap in item 1 is bounded even when somebody forgets.
The edge cases this article skipped
OAuth2AuthorizationServerConfiguration no longer exists. Every pre-7.0 Authorization Server tutorial opens with applyDefaultSecurity(http); that class and its whole package tree are absent from 7.1.1. The configurer moved into spring-security-config and the entry point is http.oauth2AuthorizationServer(..). It is a compile error, not a deprecation. Chapter 1
A public client is asked for consent mid-flow unless ClientSettings.requireAuthorizationConsent(false) is explicit, which stops any scripted redemption dead. Chapter 1
Token caching and refresh timing inside AuthorizedClientServiceOAuth2AuthorizedClientManager. Chapter 3
The full authorization_code + PKCE flow in curl, twenty-five lines, no library. user-token.sh
Why pkill -f spring-boot kills your own shell, and the safer pattern. stop.sh
No Comments yet!