Spring Security and Keycloak: Enterprise OAuth2/OIDC Guide
Instead of rewriting login in every enterprise application: a central identity provider, Keycloak. Which OAuth2/OIDC flow to use where, realm and client setup, JWT validation and role mapping in a Spring Boot resource server, client credentials between services, the BFF pattern for browser apps and a production checklist, with code examples.
Most enterprise teams rewrite the same thing in every new application: the user table, the password hash, the login screen, "forgot my password", session management. By the fifth application there are five user stores, five password policies and not a single MFA. The fix is to take identity out of the application and move it to a central identity provider (IdP); in the open-source world the standard is Keycloak, and the protocols are OAuth 2.0 and OpenID Connect.
This article covers which OAuth2/OIDC flow to use where, realm and client setup in Keycloak, configuring Spring Boot as a resource server with JWT validation and role mapping, the client-credentials flow between services, the BFF pattern for browser apps, the authorization model, a production checklist and the mistakes we see in the field.
Concepts and flows
OAuth 2.0 defines four roles: the resource owner (the user), the client (a React app, a mobile app, another service), the authorization server (Keycloak) and the resource server (the Spring Boot API). OpenID Connect adds an identity layer on top of OAuth2: the ID token carrying "who logged in". The application sees three tokens: a short-lived access token (goes to the API, a JWT), an ID token (user information for the client) and a refresh token (to get a new one).
Flow | Who uses it | Note |
|---|---|---|
Authorization Code + PKCE | Browser apps (SPA), mobile, server-side web | The standard user login; PKCE on for every client |
Client Credentials | Service to service (no user): batch jobs, integrations | With client id + secret; carries no user context |
Device Authorization | Keyboard-less devices, CLI tools | Rare; show a code, approve in a browser |
| — | Deprecated; banned in new systems (the password never enters the application) |
Architecture: who carries what?
[React SPA] ──(1) login redirect──► [Keycloak] (username/password, MFA, federation with corporate AD)
[React SPA] ◄──(2) authorization code── [Keycloak]
[React SPA] ──(3) code + PKCE verifier──► [Keycloak] ──► access + ID + refresh token
[React SPA] ──(4) Authorization: Bearer <access>──► [Spring Boot API] ──(5) signature check via JWKS──► [Keycloak]Storing tokens in the browser (localStorage) is defenseless against XSS. The recommended arrangement for enterprise browser apps is the BFF (Backend for Frontend): the browser talks to the BFF only through an HttpOnly, Secure, SameSite cookie; the tokens live in the BFF's server-side session; the BFF adds the Bearer header to API calls. Spring Security's OAuth2 client support provides this out of the box; Spring Cloud Gateway's TokenRelay filter does the same job at the gateway layer.
Keycloak setup: realm and clients
Realm: The isolated space where users, roles and clients live; one realm per company (e.g.
acme). Issuer URL:https://id.acme.com/realms/acme.Clients: A public client for the SPA (no secret, PKCE required, exact redirect URIs, restricted web origins); confidential clients for the BFF and services (secret or certificate); a separate client for the API (used as the audience, issues no tokens).
Roles and groups: Realm roles (
accounting,admin) or client roles; users into groups, groups onto roles. Which claims go into the token is decided by mappers: roles, e-mail, the tenant attribute (tenant_id).Token lifetimes: Access 5 min, refresh 30 min (rotating), SSO session by business policy (8 hours); requests for "long-lived access tokens" are refused.
Enterprise integration: LDAP/Active Directory user federation, identity brokering with Entra ID/Google, MFA with OTP/WebAuthn, brute-force protection, password policy, a corporate theme.
Spring Boot resource server: JWT validation and role mapping
# application.yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://id.acme.com/realms/acme # JWKS discovered automatically through OIDC discovery@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http, JwtAuthenticationConverter converter) throws Exception {
return http
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable) // stateless API with Bearer; no cookies, no CSRF needed
.authorizeHttpRequests(a -> a
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll())
.oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(converter)))
.build();
}
/** Converts Keycloak's realm_access.roles claim into Spring ROLE_ authorities */
@Bean
JwtAuthenticationConverter keycloakRoleConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realm = jwt.getClaimAsMap("realm_access");
List<String> roles = realm == null ? List.of() : (List<String>) realm.getOrDefault("roles", List.of());
return roles.stream().map(r -> new SimpleGrantedAuthority("ROLE_" + r.toUpperCase())).collect(Collectors.toList());
});
converter.setPrincipalClaimName("preferred_username");
return converter;
}
/** issuer + audience validation: only tokens issued for this API are accepted */
@Bean
JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
String issuer = props.getJwt().getIssuerUri();
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);
OAuth2TokenValidator<Jwt> audience = new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains("order-api"));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefaultWithIssuer(issuer), audience));
return decoder;
}
}
@RestController
@RequestMapping("/api/invoices")
class InvoiceController {
@PreAuthorize("hasRole('ACCOUNTING')")
@PostMapping
InvoiceDto create(@RequestBody @Valid InvoiceRequest request, @AuthenticationPrincipal Jwt jwt) {
String tenant = jwt.getClaimAsString("tenant_id"); // tenant context from the verified token
return service.create(tenant, jwt.getSubject(), request);
}
}Three validations are inseparable: the signature (via JWKS), the issuer (is the token really from our realm) and the audience (was it issued for this API; otherwise a token obtained for another application passes on this one). In Keycloak the audience claim is added with a mapper. A 30–60 second clock-skew tolerance is the default; JWKS keys are cached and refreshed automatically on key rotation.
Between services: client credentials
spring:
security:
oauth2:
client:
registration:
inventory-service:
provider: keycloak
client-id: order-service
client-secret: ${INVENTORY_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: inventory:read
provider:
keycloak:
issuer-uri: https://id.acme.com/realms/acme@Bean
RestClient inventoryClient(RestClient.Builder builder, OAuth2AuthorizedClientManager manager) {
var interceptor = new OAuth2ClientHttpRequestInterceptor(manager); // Spring Security 6.4+: token acquisition, caching and refresh are automatic
interceptor.setClientRegistrationIdResolver(req -> "inventory-service");
return builder.baseUrl("https://inventory.acme.internal").requestInterceptor(interceptor).build();
}If the user context must travel between services (audit, tenant), the incoming access token is forwarded (token relay) or token exchange is used; a service acting on the user's behalf under its own identity breaks traceability.
The authorization model: coarse in the token, fine in the application
The token carries only coarse roles and the tenant (accounting, tenant_id); fine decisions such as "may this user see this invoice" are made in the application: ownership, tenant match, record status. A token carrying fifty permission claims bloats, travels the network with every request and reflects a permission change only when the token is renewed. For fine-grained authorization, an in-application policy service (or an engine such as OPA) and @PreAuthorize expressions; roles aren't hard-coded, they come from configuration.
Production checklist
HTTPS everywhere; Keycloak behind a reverse proxy with correct
X-Forwarded-*; redirect URIs without wildcards.Access tokens ≤ 5–10 min, rotating refresh tokens with reuse detection on; back-channel logout for session termination.
Keycloak highly available: at least two instances, shared PostgreSQL, a cache cluster; realm export in backups; version upgrades rehearsed on a test realm.
Audit events (logins, failed logins, admin operations) to central logging; alerts on anomalous logins.
Client secrets in secret management and rotatable; MFA mandatory for the admin account; the application uses the Keycloak Admin API only through a narrowly scoped service account.
CORS for known origins only; rate limiting on the token endpoint;
/actuatorand admin endpoints closed to the outside.
Common mistakes in the field
Putting tokens in localStorage: One XSS steals the session; BFF, or at least in-memory plus short lifetimes.
Validating only the signature: Without issuer and audience checks, another realm's or another application's token passes.
Long-lived access tokens: The "users shouldn't log in often" request is met with refresh tokens and the SSO session, not by extending the access token.
Taking the password into the application (ROPC): MFA and federation are bypassed; login always happens on Keycloak's screen.
Hard-coding roles: Authorization expressions and configuration instead of
if (user.role == "admin").Running Keycloak as a single instance: When the IdP goes down, every application goes down; HA is required.
Frequently asked questions
Keycloak, or Entra ID / Okta / Auth0?
They all speak the same protocols; the Spring side doesn't change. Keycloak is open source, on your own infrastructure, with no license cost but the operational responsibility is yours. Managed services take over operations and charge per user. If the company already uses Microsoft 365, combining Entra ID as the identity source with Keycloak as the application-layer IdP is a common arrangement.
Should we move from session-based security to JWT?
For a single server-side web application, Spring's OAuth2 login plus session model is sufficient and simpler; JWT's value is where several clients and services consume the API. Both can coexist: the BFF keeps the session, the API validates JWTs.
How do we connect a legacy application to Keycloak?
If the code can't be changed, putting an OIDC proxy (such as oauth2-proxy) in front and passing identity in a header is the quick solution; if the code can be changed, it's a few days' work with Spring Security's OAuth2 login support.
Conclusion
The right arrangement for enterprise authentication: login in Keycloak (MFA, federation, password policy in one place), browser apps through a BFF over cookies, APIs as stateless resource servers validating signature + issuer + audience, client credentials between services, coarse roles in the token, fine authorization in the application and a highly available IdP. This arrangement is built once; every application after it is born without a login screen.
To move your applications to central authentication or bring your existing Keycloak setup up to production standard, see our enterprise application development and web and e-commerce development services.