Securing Java Microservices: OAuth2, JWT, and Zero Trust Architecture
The era of "perimeter security" is over. In 2023, a major financial institution discovered an attacker had moved laterally through their internal network for six months—not because the firewall failed, but because every service inside trusted every other service.
This is the problem with traditional "castle and moat" security. Once you're inside, you're trusted. In modern distributed systems, especially within fintech and enterprise environments, this is unacceptable.
This guide details how to implement a Zero Trust architecture using Spring Boot 3 and Spring Cloud Gateway. We will build a system where every request is verified, identity is propagated across the call chain, and trust is never assumed—even between your own microservices.
Core Concepts: The Security Vocabulary
Before diving into code, we must align on the vocabulary. In a microservices landscape, we deal with two distinct problems: Access (OAuth2) and Identity (OIDC).
OAuth2 vs. OIDC
Think of OAuth2 as a hotel key card—it grants you access to specific rooms (scopes) but doesn't necessarily say who you are. OIDC (OpenID Connect) is the ID you show at the front desk to get that key card.
Tech Note: OAuth2 alone can't tell you if the request came from "Alice" or "Bob"—only that the token has permission to access
/api/orders. OIDC adds the ID Token, which contains verified claims about the user's identity.
The Flows
- Authorization Code Flow: The gold standard for user-facing applications (React/Angular/Mobile). The user authenticates with an Identity Provider (IdP), and the client exchanges a code for a token.
- Client Credentials Flow: Used for machine-to-machine communication. Service A authenticates itself to Service B using a client ID and secret.
JWT (JSON Web Token)
The JWT is our stateless carrier of truth. It contains three Base64-encoded parts separated by dots:
- Header: Algorithm and token type (
{"alg": "RS256", "typ": "JWT"}) - Payload: Claims about the user (
sub,iss,exp, custom claims) - Signature: HMAC or RSA signature ensuring integrity
Because the token is cryptographically signed by the IdP, any service can validate it independently using the public key. This eliminates the need to call the IdP for every request, which is essential for horizontal scaling.
Tech Note: A common mistake is using symmetric keys (HS256) in microservices. Use asymmetric keys (RS256) so services only need the public key to verify, not the secret used for signing.
Architecture: The Policy Enforcement Point (PEP)
In our architecture, Spring Cloud Gateway acts as the Policy Enforcement Point (PEP). It is the bouncer at the door.
The Request Flow:
- User → Gateway: Initiates login, redirected to IdP (Keycloak/Auth0)
- IdP → Gateway: User authenticates, IdP returns authorization code
- Gateway → IdP: Exchanges code for Access Token + ID Token
- Gateway → Service A: Forwards request with JWT in
Authorization: Bearer <token>header - Service A → Service B: Propagates the same JWT downstream
This pattern offloads the complexity of OAuth2 dances (redirects, code exchanges) to the Gateway, keeping your microservices clean and focused on business logic. Downstream services only need to validate the signature and check claims—no session management, no cookies.
Implementation Steps
1. Configuring Spring Cloud Gateway
First, we configure the Gateway to handle the OAuth2 login and propagate the token.
Dependencies:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
Configuration (application.yml):
First, register your Gateway as an OAuth2 Client with the IdP:
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: gateway-client
client-secret: ${OAUTH2_CLIENT_SECRET}
scope: openid,profile,email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: https://idp.example.com/realms/master
Then configure routing with the TokenRelay filter, which automatically propagates the user's token:
spring:
cloud:
gateway:
routes:
- id: account-service
uri: lb://account-service
predicates:
- Path=/accounts/**
filters:
- TokenRelay
Tech Note: The
TokenRelayfilter only works if the Gateway has an active OAuth2 session. For public APIs, you'll need to handle token validation at the Gateway level usingspring-boot-starter-oauth2-resource-serverinstead.
2. Securing the Resource Server
Downstream services act as Resource Servers. They don't handle login; they just validate tokens.
Dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Security Configuration:
We configure the SecurityFilterChain to require authentication and enforce role-based access control (RBAC) based on JWT claims.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/orders/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtAuthConverter = new JwtAuthenticationConverter();
jwtAuthConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthConverter;
}
}
Tech Note: By default, Spring Security looks for authorities in the
scopeorscpclaim. If your IdP (like Keycloak) puts roles in a custom claim likerealm_access.roles, useJwtGrantedAuthoritiesConverterto extract them.
Properties:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example.com/realms/master
3. Resilience and Circuit Breaking
Security is also about availability. If your IdP goes down, your Gateway shouldn't hang.
Add Resilience4j Dependency:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>
Configure Circuit Breaker on Routes:
spring:
cloud:
gateway:
routes:
- id: account-service
uri: lb://account-service
filters:
- name: CircuitBreaker
args:
name: accountServiceBreaker
fallbackUri: forward:/fallback/accounts
- TokenRelay
Now if the downstream service is unavailable, the Gateway returns a graceful error instead of timing out.
Tech Note: For IdP failures, configure retry logic with exponential backoff when fetching JWK Sets. Spring Security caches JWK Sets by default, so temporary IdP outages won't break token validation.
Zero Trust in Practice
Validating a token proves who the user is, but it doesn't prove where the request came from. Zero Trust requires us to verify the infrastructure as well.
mTLS (Mutual TLS)
In a Zero Trust network, we assume the network is hostile. We use mTLS to ensure that Service A can only talk to Service B if it presents a valid certificate signed by our internal CA.
Server-Side Configuration (Service B validates Service A's certificate):
server:
ssl:
enabled: true
client-auth: need # Require client certificate
key-store: file:/etc/certs/service-b-keystore.p12
key-store-password: ${KEYSTORE_PASSWORD}
key-store-type: PKCS12
trust-store: file:/etc/certs/ca-truststore.p12
trust-store-password: ${TRUSTSTORE_PASSWORD}
trust-store-type: PKCS12
Client-Side Configuration (Service A presents its certificate):
@Bean
public RestClient secureRestClient(RestClient.Builder builder) {
SSLContext sslContext = SSLContexts.custom()
.loadKeyMaterial(
new File("/etc/certs/service-a-keystore.p12"),
keystorePassword.toCharArray(),
keystorePassword.toCharArray()
)
.loadTrustMaterial(new File("/etc/certs/ca-truststore.p12"))
.build();
return builder
.requestFactory(new JdkClientHttpRequestFactory(
HttpClient.newBuilder()
.sslContext(sslContext)
.build()
))
.build();
}
Tech Note: In Kubernetes, use cert-manager to automate certificate issuance and rotation. Service Mesh solutions like Istio can handle mTLS transparently at the sidecar level, removing the need for application-level configuration.
Identity Propagation
"The user is the context." When Service A calls Service B, it shouldn't just use its own credentials. It should propagate the user's JWT (via TokenRelay or manual propagation) so that Service B knows exactly which user initiated the chain. This is vital for audit trails.
Fintech & Enterprise Considerations
Compliance (PCI-DSS / HIPAA)
For regulated industries, "access" isn't enough. You need Audit Logging. Use Spring AOP to intercept every controller method and log the Principal (user ID) and the action performed.
@Aspect
@Component
public class AuditLoggingAspect {
private static final Logger auditLog = LoggerFactory.getLogger("AUDIT");
@Around("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
public Object logAuditTrail(ProceedingJoinPoint joinPoint) throws Throwable {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String userId = (auth != null) ? auth.getName() : "ANONYMOUS";
String action = joinPoint.getSignature().toShortString();
String requestId = MDC.get("requestId");
auditLog.info("USER={} ACTION={} REQUEST_ID={}", userId, action, requestId);
return joinPoint.proceed();
}
}
Tech Note: Never log the full JWT or PII (Personally Identifiable Information) in your application logs. Log the
sub(subject) claim and the request ID. For sensitive operations (e.g., financial transactions), log to a separate append-only audit database with tamper-proof guarantees.
Secrets Management
Never hardcode client-secret in your application.yml. Use Spring Cloud Config backed by HashiCorp Vault or AWS Secrets Manager to inject secrets at runtime.
Conclusion
Securing microservices is not about buying a bigger firewall. It's about implementing a defense-in-depth strategy where every component verifies identity and enforces least-privilege access.
Key Takeaways:
- Use Spring Cloud Gateway as your Policy Enforcement Point to centralize OAuth2 flows
- Validate JWTs locally in Resource Servers to eliminate round-trips to the IdP
- Implement mTLS to authenticate service-to-service communication at the transport layer
- Always propagate user context (JWTs) through the call chain for audit trails
- Use Circuit Breakers to ensure authentication failures don't cascade into system-wide outages
By combining these patterns, you build a system that is secure by design, not by accident.
Ready to build systems that scale securely? We work with teams solving these exact challenges. Check out our open roles or contact us to find engineers who master this stack.
Frequently Asked Questions
Why not just use session cookies between services?
Session cookies are stateful and difficult to manage in a distributed, polyglot environment. JWTs are stateless, allowing any service to validate the token without a database lookup, which is essential for horizontal scaling.
How do I handle token expiration during a long process?
If a process takes longer than the token's lifespan, you should implement a "Refresh Token" flow at the Gateway level or use a background worker with its own "Client Credentials" token to complete the task asynchronously.
Is mTLS overkill for internal networks?
No. In a Zero Trust model, there is no such thing as a "trusted internal network." Attackers often move laterally after breaching the perimeter. mTLS ensures that even if an attacker is on the network, they cannot communicate with your services without a valid certificate.
How do I revoke a JWT if it's stateless?
True revocation is difficult with stateless JWTs. You can implement "Reference Tokens" (where the token is just an ID checked against a cache) or maintain a short "deny list" of revoked JTI (JWT ID) claims in a fast cache like Redis.
References
- Spring Security: OAuth2 Resource Server Documentation
- Spring Cloud Gateway: TokenRelay Filter
- NIST: Zero Trust Architecture (SP 800-207)