Skip to content
Spring Boot sb auth 3 min read

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-dev disables HTTPS and uses an in-memory H2 database — fine for local development, never for production. Production runs start with a real database and TLS configured.

Realms, clients, and roles

Three Keycloak concepts map onto OAuth2:

Keycloak conceptWhat it isOAuth2 equivalent
RealmAn isolated tenant of users, roles, clientsThe authorization server boundary
ClientAn application that talks to KeycloakThe OAuth2 client / resource server
RoleA named permission, realm- or client-scopedAuthorities / scopes

Set up a realm for your app:

  1. In the admin console, create a realm named devcraftly.
  2. Create a client spring-api — for a resource server, an OIDC client with a service or public access type.
  3. Under Realm roles, create USER and ADMIN.
  4. 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 the issuer-uri to 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.roles actually contains the roles you assigned — the most common cause of an unexpected 403 is the role not being in the token.

Last updated June 13, 2026
Was this helpful?