Back to Articles
February 15, 2026 10 min read

Securing Java Microservices: OAuth2, JWT, and Zero Trust Architecture

Production security for Spring Boot microservices. Implement OAuth2, JWT validation, API Gateway security, and Zero Trust for fintech and enterprise systems.

Spring Security OAuth2 Zero Trust Microservices Spring Cloud Gateway
Securing Java Microservices: OAuth2, JWT, and Zero Trust Architecture

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:

  1. UserGateway: Initiates login, redirected to IdP (Keycloak/Auth0)
  2. IdPGateway: User authenticates, IdP returns authorization code
  3. GatewayIdP: Exchanges code for Access Token + ID Token
  4. GatewayService A: Forwards request with JWT in Authorization: Bearer <token> header
  5. Service AService 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 TokenRelay filter only works if the Gateway has an active OAuth2 session. For public APIs, you'll need to handle token validation at the Gateway level using spring-boot-starter-oauth2-resource-server instead.

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 scope or scp claim. If your IdP (like Keycloak) puts roles in a custom claim like realm_access.roles, use JwtGrantedAuthoritiesConverter to 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

Looking for Your Next Role?

Let us help you find the perfect software engineering opportunity.

Explore Opportunities