Keycloak Integration
Keycloak is an open-source identity and access management server — a full authorization server you run yourself instead of relying on Google or GitHub. It issues OIDC tokens, manages users, roles, and clients, and exposes a JWKS endpoint your Spring Boot API can validate against. This page runs Keycloak in Docker and secures a Spring Boot resource server against it, including the realm/client role mapping that Keycloak does differently from generic providers.
Running Keycloak in Docker
The official image starts a dev-mode server with an admin account in one command:
docker run -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.0 start-dev
The admin console is then at http://localhost:8080/admin (admin/admin).
Warning:
start-devdisables HTTPS and uses an in-memory H2 database — fine for local development, never for production. Production runsstartwith a real database and TLS configured.
Realms, clients, and roles
Three Keycloak concepts map onto OAuth2:
| Keycloak concept | What it is | OAuth2 equivalent |
|---|---|---|
| Realm | An isolated tenant of users, roles, clients | The authorization server boundary |
| Client | An application that talks to Keycloak | The OAuth2 client / resource server |
| Role | A named permission, realm- or client-scoped | Authorities / scopes |
Set up a realm for your app:
- In the admin console, create a realm named
devcraftly. - Create a client
spring-api— for a resource server, an OIDC client with a service or public access type. - Under Realm roles, create
USERandADMIN. - Create a user (set a password under Credentials) and assign roles under Role mapping.
The realm’s OIDC issuer is then:
http://localhost:8080/realms/devcraftly
and its discovery document lives at http://localhost:8080/realms/devcraftly/.well-known/openid-configuration.
Configuring Spring Boot as a resource server
Point the resource server at the realm issuer. Spring discovers the JWKS endpoint and validates the iss claim automatically.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/devcraftly
Note: If both Keycloak and your app try to use port 8080, change one of them. Run Spring Boot on
server.port=9000, or map Keycloak to a different host port (-p 8081:8080) and update theissuer-urito match.
Mapping Keycloak roles to authorities
Keycloak does not put roles in a plain roles claim. Realm roles live under realm_access.roles and client roles under resource_access.<client>.roles. The default SCOPE_ mapping therefore misses them — you need a custom JwtAuthenticationConverter that reads those nested claims.
A sample Keycloak access token:
{
"sub": "f7d3...",
"preferred_username": "alice",
"realm_access": { "roles": ["USER", "ADMIN", "default-roles-devcraftly"] },
"resource_access": { "spring-api": { "roles": ["reports:read"] } },
"iss": "http://localhost:8080/realms/devcraftly"
}
import org.springframework.context.annotation.Bean;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import java.util.*;
import java.util.stream.Collectors;
@Bean
JwtAuthenticationConverter keycloakJwtConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setPrincipalClaimName("preferred_username");
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null || realmAccess.get("roles") == null) {
return List.of();
}
@SuppressWarnings("unchecked")
Collection<String> roles = (Collection<String>) realmAccess.get("roles");
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet());
});
return converter;
}
Wire it into the filter chain exactly as on the resource server page:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakJwtConverter())))
.build();
}
}
Because authorities are prefixed ROLE_, both hasRole("ADMIN") here and @PreAuthorize("hasRole('ADMIN')") via method security work as expected.
Testing with a token
Grab a token straight from Keycloak’s token endpoint (using the password grant only for quick local testing):
TOKEN=$(curl -s -X POST \
http://localhost:8080/realms/devcraftly/protocol/openid-connect/token \
-d 'grant_type=password' \
-d 'client_id=spring-api' \
-d 'username=alice' \
-d 'password=secret' | jq -r '.access_token')
curl -s http://localhost:9000/api/admin/users \
-H "Authorization: Bearer $TOKEN"
Output (alice has the ADMIN role):
[ { "id": 1, "username": "alice" } ]
A user without ADMIN gets 403 Forbidden; a missing or expired token gets 401 Unauthorized with a WWW-Authenticate header.
Tip: Paste the access token into jwt.io (or decode the JWT yourself) to confirm
realm_access.rolesactually contains the roles you assigned — the most common cause of an unexpected 403 is the role not being in the token.