Home Java OAuth2 with Spring Boot and Keycloak: OpenID Connect and SSO Implementation
Advanced 6 min · July 14, 2026

OAuth2 with Spring Boot and Keycloak: OpenID Connect and SSO Implementation

Learn how to implement OAuth2 and OpenID Connect in Spring Boot 3.2 with Keycloak 22 for SSO.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 20-25 min read
  • Java 17+ and Spring Boot 3.2+ project with spring-boot-starter-web and spring-boot-starter-oauth2-resource-server
  • Running Keycloak 22+ instance (Docker image quay.io/keycloak/keycloak:22.0.0)
  • Basic understanding of OAuth2 flows (authorization code, client credentials) and JWT structure
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

• OAuth2 with Spring Boot and Keycloak enables secure SSO using OpenID Connect (OIDC) for authentication and OAuth2 for authorization. • Keycloak acts as the identity provider (IdP) managing user credentials, roles, and tokens. • Spring Boot 3.2 with spring-boot-starter-oauth2-client handles token validation and session management. • You need to configure Keycloak realms, clients, and role mappings, then secure your REST APIs with @PreAuthorize and SecurityFilterChain. • Common production issues include token expiration, CORS misconfiguration, and role prefix mismatches.

✦ Definition~90s read
What is OAuth2 with Spring Boot and Keycloak?

OAuth2 with Spring Boot and Keycloak is a security pattern where you delegate authentication to Keycloak (the authorization server) and use Spring Security to protect your REST APIs with JWT tokens issued by Keycloak.

Think of Keycloak as the bouncer at a VIP club.
Plain-English First

Think of Keycloak as the bouncer at a VIP club. You show your ID (login credentials) once, and the bouncer gives you a wristband (access token). That wristband lets you enter any room (microservice) in the club without showing your ID again. OAuth2 is the rulebook for how the wristband works, and OpenID Connect is the bouncer's way of verifying who you are.

OAuth2 and OpenID Connect (OIDC) have become the de facto standard for securing microservices and enabling Single Sign-On (SSO) in modern Java applications. Spring Boot 3.2 provides first-class support for OAuth2 through the spring-boot-starter-oauth2-client and spring-boot-starter-oauth2-resource-server starters, but the official documentation often glosses over real-world production nuances. In this tutorial, we will integrate Spring Boot with Keycloak 22 (the latest stable version as of 2024) to implement a complete OAuth2 + OIDC flow. You will learn how to set up a Keycloak realm, configure an OAuth2 client in Spring Boot, secure REST endpoints with role-based access, and validate JWT tokens. We will also cover common production incidents like token refresh failures, CORS errors in SPA integrations, and role prefix mismatches between Keycloak and Spring Security. By the end, you will have a working SSO setup that can serve as the foundation for a multi-service SaaS billing platform or a real-time analytics dashboard. I assume you have a running Keycloak instance (Docker recommended) and basic familiarity with Spring Boot 3.2. Let's skip the Hello World and get to the hard stuff.

Setting Up Keycloak Realm and Client

First, you need a Keycloak realm and a client. Let's create a realm called 'saas-billing' and a client named 'payment-service'. In Keycloak Admin Console (http://localhost:8080), go to Realms > Create Realm. Set the realm name to 'saas-billing'. Then create a client: Client ID = 'payment-service', Client authentication = ON (for confidential client), Service accounts roles = ON, Standard flow = ON. Save and note the client secret under the Credentials tab. Also create a test user under Users: username = 'john_doe', email = john@example.com, set a password. Assign the role 'admin' under Role Mappings (we'll create a realm role named 'admin' first). This setup mimics a real SaaS billing system where the payment service needs to authenticate users and validate their roles. The client secret will be used in Spring Boot for the authorization code flow. For resource server mode (API-only), you don't need the secret — just the issuer URI. But for SSO with a UI, you'll need the client secret for token exchange. I recommend using Docker: docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:22.0.0 start-dev. This gives you a local Keycloak with the admin console at http://localhost:8080.

docker-compose.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
version: '3.8'
services:
  keycloak:
    image: quay.io/keycloak/keycloak:22.0.0
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    ports:
      - "8080:8080"
    command: start-dev
  payment-service:
    build: .
    ports:
      - "8081:8081"
    depends_on:
      - keycloak
Output
Keycloak starts on port 8080, payment service on 8081.
⚠ Keycloak Version Matters
📊 Production Insight
In production, never use the master realm for your applications. Create a separate realm like 'saas-billing-prod' and restrict admin access to the master realm to infrastructure teams only.
🎯 Key Takeaway
Create a dedicated Keycloak realm per environment (dev, staging, prod) to isolate user databases and client configurations.

What the Official Docs Won't Tell You

The Spring Security reference documentation for OAuth2 resource server is decent, but it hides three landmines. First, the default JwtDecoder uses the JWK Set URI from the issuer-uri property, but it caches the keys forever. If you rotate keys in Keycloak (which you should do regularly), your service will reject tokens until you restart. Solution: configure a Caffeine cache with a TTL for the JWK set. Second, role mapping is not automatic. Keycloak sends roles as a 'realm_access.roles' claim, but Spring Security expects 'roles' or 'authorities'. You must write a custom GrantedAuthoritiesMapper to extract roles from the correct claim. Third, CORS is a nightmare when integrating with SPAs. Keycloak's default CORS policy blocks all origins except localhost. You must explicitly configure allowed origins in the Keycloak client settings under 'Web Origins'. I've seen teams waste days debugging 403 errors from their React frontend because they forgot to add 'http://localhost:3000' to Web Origins. Another gotcha: the authorization code flow requires a redirect URI that exactly matches what's configured in Keycloak. If you use HTTPS locally with a self-signed certificate, Keycloak will reject the redirect. Use http://localhost for local development. Finally, token introspection is slow in Keycloak — prefer local JWT validation with the JWK set for performance.

SecurityConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("admin")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(jwtDecoder())
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            );
        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder decoder = NimbusJwtDecoder
            .withJwkSetUri("http://keycloak:8080/realms/saas-billing/protocol/openid-connect/certs")
            .cache(Caffeine.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build()::get)
            .build();
        return decoder;
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
        converter.setAuthorityPrefix("ROLE_");
        converter.setAuthoritiesClaimName("realm_access.roles");
        JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
        jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
        return jwtConverter;
    }
}
Output
Spring Boot validates JWT using JWK set from Keycloak, extracts roles from 'realm_access.roles' claim, and maps them to Spring Security authorities with ROLE_ prefix.
🔥JWK Cache TTL
📊 Production Insight
In a real-time analytics system with 10k requests/second, avoid token introspection. Use local JWT validation with JWK set. Introspection adds 5-10ms latency per request, which kills throughput.
🎯 Key Takeaway
Always configure a custom JwtDecoder with a JWK cache TTL and a custom JwtAuthenticationConverter for role mapping. Never rely on defaults.

Configuring Spring Boot Application Properties

The application.yml file is where you wire Keycloak to Spring Boot. For a resource server (API-only), you only need the issuer-uri. For a client (SSO with login page), you need the registration details. I'll show both. The critical property is spring.security.oauth2.resourceserver.jwt.issuer-uri. This must point to your Keycloak realm's issuer URL, e.g., http://keycloak:8080/realms/saas-billing. Spring Boot will automatically fetch the JWK set URI from the OpenID Connect discovery endpoint (/.well-known/openid-configuration). If you're running Keycloak behind a reverse proxy (like Nginx), make sure the issuer-uri matches the external URL, not the internal one. Otherwise, token validation will fail because the 'iss' claim in the JWT will not match. For the OAuth2 client configuration (if you want Spring Boot to handle the login flow), add spring.security.oauth2.client.registration.keycloak.client-id and client-secret. The provider details can be auto-configured from the issuer-uri as well. I prefer using environment variables for the client secret — never hardcode it in application.yml. Use ${KEYCLOAK_CLIENT_SECRET} and pass it via Docker or Kubernetes secrets.

application.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://keycloak:8080/realms/saas-billing
      client:
        registration:
          keycloak:
            client-id: payment-service
            client-secret: ${KEYCLOAK_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            scope: openid, profile, email
        provider:
          keycloak:
            issuer-uri: http://keycloak:8080/realms/saas-billing
server:
  port: 8081
Output
Spring Boot auto-configures OAuth2 resource server and client. The issuer-uri is used to discover JWK set and token endpoints.
⚠ Issuer URI Must Match Exactly
📊 Production Insight
In a Kubernetes cluster, use the Keycloak service DNS name (e.g., http://keycloak.keycloak.svc.cluster.local:8080/realms/saas-billing) for issuer-uri. Avoid using external URLs for inter-service communication to reduce latency.
🎯 Key Takeaway
Use issuer-uri for resource server auto-configuration. It eliminates the need to manually specify JWK set URI, token endpoint, etc.

Securing REST Endpoints with Role-Based Access

Now we protect our payment-processing endpoints. Use @PreAuthorize annotations on controller methods for fine-grained access control. First, enable method security with @EnableMethodSecurity in your configuration. Then define roles as realm roles in Keycloak (e.g., 'admin', 'user'). In the JwtAuthenticationConverter we configured earlier, roles from the 'realm_access.roles' claim are mapped to Spring Security authorities with the 'ROLE_' prefix. This means @PreAuthorize("hasRole('admin')") will match a user with the 'admin' realm role. For endpoint-level security, you can also use requestMatchers in the SecurityFilterChain. I prefer a hybrid approach: use requestMatchers for broad categories (e.g., /api/public/**) and @PreAuthorize for fine-grained control. One common mistake is forgetting that hasRole() expects the role name without the 'ROLE_' prefix (Spring adds it automatically). Another is mixing up hasAuthority() and hasRole(). Use hasRole() for realm roles and hasAuthority() for client roles or custom claims. In a SaaS billing system, you might have endpoints like /api/invoices that require 'admin' role, and /api/invoices/{id} that require 'user' role and ownership validation.

PaymentController.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    @GetMapping
    @PreAuthorize("hasRole('admin')")
    public List<Payment> getAllPayments() {
        return paymentService.findAll();
    }

    @GetMapping("/{id}")
    @PreAuthorize("hasRole('user') and @paymentSecurity.isOwner(#id, authentication)")
    public Payment getPayment(@PathVariable Long id, Authentication authentication) {
        return paymentService.findById(id);
    }

    @PostMapping
    @PreAuthorize("hasRole('admin')")
    public Payment createPayment(@RequestBody PaymentRequest request) {
        return paymentService.create(request);
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('admin')")
    public void deletePayment(@PathVariable Long id) {
        paymentService.delete(id);
    }
}
Output
Only users with 'admin' realm role can access GET /api/payments and POST/DELETE. Users with 'user' role can access their own payments via @paymentSecurity.isOwner().
🔥Method Security Expression
📊 Production Insight
In a high-traffic system, avoid database queries in @PreAuthorize expressions. Cache ownership data in Redis or use a lightweight in-memory map. Otherwise, each request will hit the database, causing latency spikes under load.
🎯 Key Takeaway
Combine requestMatchers for broad security with @PreAuthorize for fine-grained access. Always validate ownership for multi-tenant data.

Handling Token Refresh and Session Management

Access tokens have a short lifetime (default 5 minutes in Keycloak). Refresh tokens live longer (default 30 minutes). For a seamless user experience, you need to handle token refresh. In a Spring Boot resource server (API-only), you don't manage refresh tokens — the client (e.g., a React SPA) handles that. But if you're using Spring Boot as an OAuth2 client (with a login page), Spring Security handles refresh automatically via the OAuth2AuthorizedClientManager. The tricky part is when the refresh token expires. Keycloak returns a new refresh token with each refresh, but if the user is inactive for 30 minutes, the refresh token expires and the user must re-login. To extend sessions, you can configure Keycloak's SSO Session Idle timeout. I set mine to 8 hours for a billing system where users work all day. Another production issue: if you have multiple microservices, each service needs to validate the access token independently. But token refresh should happen only at the edge (API gateway or SPA). Never let microservices refresh tokens — that creates tight coupling and security holes. For real-time analytics, consider using opaque tokens with introspection if you need to revoke tokens immediately. JWT tokens cannot be revoked until they expire, unless you maintain a blacklist in Redis.

TokenRefreshConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
public class TokenRefreshConfig {

    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository authorizedClientRepository) {
        OAuth2AuthorizedClientProvider provider = OAuth2AuthorizedClientProviderBuilder.builder()
            .authorizationCode()
            .refreshToken()
            .clientCredentials()
            .build();
        DefaultOAuth2AuthorizedClientManager manager = new DefaultOAuth2AuthorizedClientManager(
            clientRegistrationRepository, authorizedClientRepository);
        manager.setAuthorizedClientProvider(provider);
        return manager;
    }

    @Bean
    public OAuth2AuthorizedClientRepository authorizedClientRepository() {
        return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository();
    }
}
Output
Spring Boot automatically uses refresh_token grant when the access token expires. The OAuth2AuthorizedClientManager handles token refresh transparently.
⚠ Refresh Token Rotation
📊 Production Insight
For a payment-processing system with idempotency requirements, use short-lived access tokens (1-2 minutes) and force token refresh before each payment operation. This ensures that if a token is leaked, the window of vulnerability is minimal.
🎯 Key Takeaway
Token refresh should happen at the edge (API gateway or SPA). Microservices should only validate access tokens, never refresh them.

Testing the OAuth2 Flow with Spring Boot

Testing OAuth2 flows is notoriously difficult because you need a running Keycloak instance and valid tokens. I use Testcontainers to spin up a Keycloak container in integration tests. This gives you a real Keycloak that you can configure programmatically. For unit tests, you can mock the JwtDecoder and return a fake Jwt token. But integration tests with Testcontainers catch real issues like role mapping mismatches or issuer URI errors. I'll show you a test that creates a Keycloak realm, a client, a user with roles, then calls your secured endpoint. You need the Keycloak admin client (org.keycloak:keycloak-admin-client:22.0.0) to create the realm programmatically. The test gets an access token for the test user, then calls the API. This is the only reliable way to test OAuth2 security. Mocking the security context is fine for unit tests, but never ship without an integration test that validates the full OAuth2 flow. I've seen teams deploy broken security because they only tested with mocked tokens that didn't match the real Keycloak claims structure.

OAuth2IntegrationTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OAuth2IntegrationTest {

    @Container
    static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:22.0.0")
        .withRealmImportFile("saas-billing-realm.json");

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void testSecuredEndpointWithValidToken() {
        String token = keycloak.getAccessToken("john_doe", "password");
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(token);
        ResponseEntity<String> response = restTemplate.exchange(
            "/api/payments", HttpMethod.GET, new HttpEntity<>(headers), String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }

    @Test
    void testSecuredEndpointWithoutToken() {
        ResponseEntity<String> response = restTemplate.exchange(
            "/api/payments", HttpMethod.GET, null, String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }
}
Output
Testcontainers starts Keycloak, imports a realm, gets a token for test user john_doe, and verifies that the secured endpoint returns 200 with valid token and 401 without.
🔥Realm Import File
📊 Production Insight
Add a health check endpoint that validates the connection to Keycloak's JWK set URI. This catches network issues or Keycloak outages before they affect users. Integrate this with Spring Boot Actuator's health indicator.
🎯 Key Takeaway
Use Testcontainers with Keycloak for integration tests. Never rely solely on mocked security contexts for OAuth2 testing.

Production Debugging: CORS and Token Validation Issues

CORS is the number one issue when integrating a React SPA with Spring Boot and Keycloak. The browser sends a preflight OPTIONS request, and if the server doesn't respond with the correct CORS headers, the actual request fails with a misleading error. In Keycloak, you must configure 'Web Origins' in the client settings to include your SPA's origin (e.g., http://localhost:3000). For production, use the actual domain. Additionally, Spring Boot's resource server must handle CORS itself because the browser sends the preflight before the Authorization header. Configure a CorsConfigurationSource bean that allows your SPA origin, methods, and headers. Another common issue is token validation failing due to clock skew. If your Keycloak server and Spring Boot service have different system times (more than a few seconds), JWT validation will fail because the 'nbf' (not before) or 'exp' (expiration) claims are in the future or past. Use NTP to synchronize clocks across all servers. For debugging token issues, log the JWT claims during development. Add a filter that prints the decoded token. I use a custom OncePerRequestFilter that logs the principal and authorities. This helps identify role mapping issues quickly. Finally, never log the full JWT in production — it contains sensitive user information. Log only the claims you need.

CorsConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(Arrays.asList(
            "http://localhost:3000",
            "https://app.saas-billing.com"
        ));
        config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}
Output
Spring Boot returns CORS headers for allowed origins. Preflight requests are handled correctly. Credentials (Authorization header) are allowed.
⚠ CORS and Authorization Header
📊 Production Insight
In a real-time analytics dashboard, CORS preflight adds 1-2 round trips. Set a high maxAge (1 hour) to reduce preflight requests. But be careful — if you change CORS config, clients will use the cached preflight for up to 1 hour.
🎯 Key Takeaway
Configure CORS in both Keycloak (Web Origins) and Spring Boot (CorsConfigurationSource). Use a short maxAge (3600 seconds) to avoid stale preflight cache.

Advanced: Client Credentials Flow for Machine-to-Machine

Not all clients are users. In a SaaS billing system, you have background jobs, batch processors, and other microservices that need to call APIs without user interaction. For these, use the client credentials flow. Configure a separate Keycloak client (e.g., 'batch-processor') with 'Service accounts roles' enabled. Assign roles to the service account (not a user). In Spring Boot, use the OAuth2ClientCredentialsGrantRequestEntityConverter to get a token for the client. Then use a RestTemplate or WebClient with the OAuth2AuthorizedClientManager to automatically obtain and refresh tokens. The key difference: the token contains no user information — only client roles. This flow is ideal for batch jobs that process invoices at midnight. The token is long-lived (you can set it to 1 hour), but you still need to handle refresh. One gotcha: Keycloak's service account roles are mapped to the 'resource_access.{client-id}.roles' claim, not 'realm_access.roles'. So you need a separate JwtAuthenticationConverter for client credentials tokens that extracts roles from the correct claim. I use a custom JwtAuthenticationConverter that checks both claims and merges them. This way, the same SecurityConfig works for both user tokens and service account tokens.

ClientCredentialsConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
public class ClientCredentialsConfig {

    @Bean
    public OAuth2AuthorizedClientManager clientCredentialsManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository authorizedClientRepository) {
        OAuth2AuthorizedClientProvider provider = OAuth2AuthorizedClientProviderBuilder.builder()
            .clientCredentials()
            .build();
        DefaultOAuth2AuthorizedClientManager manager = new DefaultOAuth2AuthorizedClientManager(
            clientRegistrationRepository, authorizedClientRepository);
        manager.setAuthorizedClientProvider(provider);
        return manager;
    }

    @Bean
    public WebClient webClient(OAuth2AuthorizedClientManager manager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction filter =
            new ServletOAuth2AuthorizedClientExchangeFilterFunction(manager);
        filter.setDefaultOAuth2AuthorizedClient(true);
        return WebClient.builder()
            .filter(filter)
            .build();
    }
}
Output
WebClient automatically obtains a client credentials token from Keycloak for the 'batch-processor' client and uses it for API calls.
🔥Service Account Roles
📊 Production Insight
For a batch job that processes millions of invoices, use a dedicated service account with read-only roles. Never give write access to batch processors unless absolutely necessary. If the batch processor is compromised, the blast radius is limited.
🎯 Key Takeaway
Use client credentials flow for machine-to-machine communication. Configure a separate Keycloak client per microservice with minimal roles.
● Production incidentPOST-MORTEMseverity: high

The Silent 401 After Token Refresh

Symptom
Users reported being logged out after exactly 5 minutes of inactivity. Logs showed 'Invalid token' errors on every endpoint.
Assumption
The team assumed Keycloak's access token lifetime was the problem, so they increased it to 30 minutes. The issue persisted.
Root cause
Spring Boot's NimbusJwtDecoder was using the wrong JWK set URI. The Keycloak realm had a custom token issuer, but the application.properties still pointed to the default master realm. After token refresh, the new token had a different 'iss' claim that didn't match the configured issuer, causing validation to fail.
Fix
Updated spring.security.oauth2.resourceserver.jwt.issuer-uri to the correct Keycloak realm URL (e.g., http://keycloak:8080/realms/saas-billing). Also added a JwtDecoder bean with explicit issuer validation.
Key lesson
  • Always verify the issuer URI matches the Keycloak realm exactly, including the /realms/ path.
  • Use a custom JwtDecoder bean to log token claims during development for easier debugging.
  • Never assume token lifetime is the root cause of 401 errors — always check token claims first.
Production debug guideStep-by-step troubleshooting for common Keycloak + Spring Boot issues4 entries
Symptom · 01
401 Unauthorized on all endpoints
Fix
Check the JWT token at jwt.io. Verify the 'iss' claim matches spring.security.oauth2.resourceserver.jwt.issuer-uri. Check clock skew between Keycloak and your service.
Symptom · 02
403 Forbidden after successful authentication
Fix
Check the 'realm_access.roles' claim in the JWT. Verify your JwtAuthenticationConverter is extracting roles correctly. Ensure @PreAuthorize uses the correct role name.
Symptom · 03
CORS error in browser console
Fix
Check Keycloak client 'Web Origins' setting. Verify Spring Boot's CorsConfigurationSource includes the SPA origin. Check that the preflight OPTIONS request returns 200 with correct headers.
Symptom · 04
Token refresh fails with 'invalid_grant'
Fix
Check if refresh token rotation is enabled in Keycloak. Ensure concurrent refresh requests are serialized. Verify the refresh token hasn't expired.
★ OAuth2 Quick Debug Cheat SheetImmediate commands and actions for common OAuth2 issues in production
401 - Invalid token
Immediate action
Decode the JWT at jwt.io and check 'iss' claim
Commands
curl -k https://keycloak:8080/realms/saas-billing/.well-known/openid-configuration | jq .issuer
curl -k https://keycloak:8080/realms/saas-billing/protocol/openid-connect/certs | jq .keys[0].kid
Fix now
Update issuer-uri in application.yml to match the 'iss' claim from the JWT
403 - Forbidden+
Immediate action
Check the JWT's 'realm_access.roles' claim
Commands
echo $JWT | cut -d'.' -f2 | base64 -d | jq .realm_access.roles
kubectl logs pod/payment-service | grep -i 'authorities'
Fix now
Add missing role to the user in Keycloak or fix the JwtAuthenticationConverter
CORS error+
Immediate action
Check the preflight response headers
Commands
curl -X OPTIONS -H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: GET" -v https://api.saas-billing.com/api/payments 2>&1 | grep -i 'access-control'
kubectl exec -it pod/payment-service -- curl -s http://localhost:8081/actuator/health | jq .
Fix now
Add the SPA origin to Keycloak Web Origins and Spring Boot CorsConfigurationSource
FeatureOAuth2 Resource ServerOAuth2 Client
Primary use caseSecuring REST APIs with JWT validationHandling user login flow with SSO
Token managementValidates access tokens onlyManages access and refresh tokens
Configuration complexityLow (issuer-uri only)Medium (client ID, secret, redirect URI)
Role mappingRequired (custom JwtAuthenticationConverter)Automatic (Spring Security handles it)
Ideal forMicroservices, APIsSPAs, traditional web apps
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
docker-compose.ymlversion: '3.8'Setting Up Keycloak Realm and Client
SecurityConfig.java@ConfigurationWhat the Official Docs Won't Tell You
application.ymlspring:Configuring Spring Boot Application Properties
PaymentController.java@RestControllerSecuring REST Endpoints with Role-Based Access
TokenRefreshConfig.java@ConfigurationHandling Token Refresh and Session Management
OAuth2IntegrationTest.java@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)Testing the OAuth2 Flow with Spring Boot
CorsConfig.java@ConfigurationProduction Debugging
ClientCredentialsConfig.java@ConfigurationAdvanced

Key takeaways

1
Always configure a custom JwtDecoder with a JWK cache TTL and a custom JwtAuthenticationConverter for role mapping. Never rely on defaults.
2
Use Testcontainers with Keycloak for integration tests to catch real OAuth2 issues like role mapping mismatches and issuer URI errors.
3
Token refresh should happen at the edge (API gateway or SPA). Microservices should only validate access tokens, never refresh them.
4
Configure CORS in both Keycloak (Web Origins) and Spring Boot (CorsConfigurationSource) to avoid silent 403 errors in SPA integrations.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the OAuth2 authorization code flow with PKCE and how Spring Boot...
Q02SENIOR
How do you handle JWT token revocation in a Spring Boot microservice arc...
Q03SENIOR
What is the difference between hasRole() and hasAuthority() in Spring Se...
Q01 of 03SENIOR

Explain the OAuth2 authorization code flow with PKCE and how Spring Boot handles it.

ANSWER
The authorization code flow with PKCE (Proof Key for Code Exchange) is used by SPAs and mobile apps. The client generates a code_verifier (random string) and a code_challenge (SHA-256 hash of verifier). The user is redirected to Keycloak's authorize endpoint, which returns an authorization code. The client exchanges the code + verifier for tokens. Spring Boot's OAuth2 client handles this transparently if you configure the client registration with authorization-grant-type: authorization_code and include scope: openid. The OAuth2AuthorizedClientManager manages the token exchange and refresh.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between OAuth2 and OpenID Connect (OIDC) in Spring Boot?
02
Why does my Spring Boot application return 401 even with a valid Keycloak token?
03
How do I map Keycloak roles to Spring Security authorities?
04
Can I use Keycloak with Spring Boot without a running Keycloak instance?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Security. Mark it forged?

6 min read · try the examples if you haven't

Previous
Spring Boot Security Auto-Configuration: Defaults and Customization
8 / 31 · Spring Security
Next
Registration with Spring Security: Email Verification and Account Activation