Skip to main content

Keycloak with Spring Boot 4.1: Roles, Resource Server and Admin API

Keycloak 26.8 with Spring Boot 4.1 and Spring Security 7.1, tested against a real Keycloak: why roles are silently ignored, how a missing audience or a different host turns a valid token into a 401, method security, and managing users with the Keycloak admin client.

Putting Keycloak in front of a Spring Boot API takes about ten lines of configuration, and it appears to work: a valid token gets in, a missing one gets a 401. The problems show up later and quietly. Every role check fails, because Spring Security never looked where Keycloak put the roles. One user is rejected with a 401 while the other, using the same client and the same API, is accepted. A token obtained through 127.0.0.1 is refused although it is perfectly valid. This post reproduces each of these against a real Keycloak server, shows the code that fixes them, and then uses the Keycloak admin client to manage users from the application.

Versions: Keycloak server 26.8.1 (on Quarkus 3.40.1), Spring Boot 4.1.1, Spring Security 7.1.1, keycloak-admin-client 26.0.12, JDK 25.0.4.1. All code is in the keycloak project of the spring-auth-demo repository. ./scripts/run-all.sh downloads Keycloak, imports the realm, runs the tests and rewrites the output under cap/.

What was and was not run: there is no Docker on the build machine, so Keycloak ran from its Maven Central distribution with kc.sh start-dev, not in a container. The Testcontainers version of the test is included, compiles, and was skipped, so it is untested. start-dev is a development mode (plain HTTP, embedded H2 database); nothing here says how Keycloak behaves in a production setup with TLS and a fixed hostname.

What goes wrong, in the order you meet it

  • Roles are ignored. Spring’s default converter reads the scope claim. Keycloak’s roles live in realm_access.roles and resource_access.<client>.roles, so hasRole('admin') is false for an admin (section 3).
  • The audience is not what you assume. One user’s token had no aud claim at all, while another user’s token from the same client carried spring-api. With audience validation on, the first got a 401 (sections 2 and 5).
  • The issuer includes the host. The same login through 127.0.0.1 instead of localhost produced a token with a different iss, and the API refused it (sections 2 and 5).
  • 401 and 403 mean different things. 401 is “I do not accept this token”; 403 is “I accept it, but you may not do this” (section 5).
  • The application must not use the caller’s token to manage users. The admin API needs its own credentials with narrow roles (section 6).
Clientalice / bob / curl Keycloakrealm “demo” Spring APIresource server 1. log in, receive a JWT (password grant, for the demo) 2. Bearer JWT 3. signing keys 4. admin API Step 3 happens occasionally: the API caches Keycloak’s public keys and checks every token itself. Step 4 uses a separate service account, never the caller’s token.
The four interactions in this post. Keycloak is only on the request path for logging in (1) and for administration (4); tokens are checked locally by the API.

That last point shapes the whole design. Because the API validates tokens itself against Keycloak’s published public keys, a running Keycloak is not needed for every request. It also means the API trusts exactly what is inside the token, and the rest of this post is about checking that what is inside is what you think.

1. The realm, and a real Keycloak to run it in

The realm is imported from one JSON file when Keycloak starts, so the whole setup is reproducible. It defines two realm roles (user, admin), one client role (reports-write on the spring-api client), two users, and four clients: the API itself, two public clients for logging in, and a service account for the admin API. The two login clients are identical except that demo-cli-aud has an audience mapper; that difference is the point of section 2.

Going deeper: the realm file, piece by piece

Source: realm/demo-realm.json. The parts that matter:

"roles": {
  "realm":  [ { "name": "user" }, { "name": "admin" } ],
  "client": { "spring-api": [ { "name": "reports-write" } ] }
},
"clients": [
  { "clientId": "spring-api", "bearerOnly": true },
  { "clientId": "demo-cli",     "publicClient": true, "directAccessGrantsEnabled": true },
  { "clientId": "demo-cli-aud", "publicClient": true, "directAccessGrantsEnabled": true,
    "protocolMappers": [ { "name": "spring-api-audience", "protocolMapper": "oidc-audience-mapper",
      "config": { "included.client.audience": "spring-api", "access.token.claim": "true", "id.token.claim": "false" } } ] },
  { "clientId": "admin-svc", "publicClient": false, "secret": "admin-svc-demo-secret", "serviceAccountsEnabled": true }
],
"users": [
  { "username": "alice", "realmRoles": [ "user" ] },
  { "username": "bob",   "realmRoles": [ "user", "admin" ], "clientRoles": { "spring-api": [ "reports-write" ] } },
  { "username": "service-account-admin-svc", "serviceAccountClientId": "admin-svc",
    "clientRoles": { "realm-management": [ "manage-users", "view-users", "view-realm" ] } }
]

directAccessGrantsEnabled is the password grant. It is used here only so a test can log in without a browser; the OAuth 2.1 draft leaves it out, and a real application should use the authorization code flow with PKCE. The abbreviated listing omits e-mail, names and password credentials, which the file does set. I included them because Keycloak 26 can demand a complete profile before it lets a user log in; I did not test an incomplete user, so that reason is from memory of the behaviour and not something this run showed. The passwords and the admin-svc secret are demo values.

Keycloak starts with kc.sh start-dev --http-port=8180 --import-realm from the unpacked distribution. The script captures these lines from its log:

Keycloak distribution: keycloak-quarkus-dist-26.8.1 from Maven Central (start-dev, built-in H2 database)
INFO  [org.keycloak.services] (main) KC-SERVICES0030: Full model import requested. Strategy: IGNORE_EXISTING
INFO  [org.keycloak.exportimport.util.ImportUtils] (main) Realm 'demo' imported
INFO  [io.quarkus] (main) Keycloak 26.8.1 on JVM (powered by Quarkus 3.40.1) started in 30.899s. Listening on: http://localhost:8180

Source: cap/05-keycloak-startup.txt, produced by run-all.sh

That is a 31-second start on a 2-CPU machine, a figure to keep in mind when deciding how to test: a Keycloak container per test class is slow, so share one per test run.

2. What is actually in the token

Before writing any Spring code, look at what Keycloak issues. The test logs in as each user and prints the claims it will need (decoded without verification, for printing only):

user=alice client=demo-cli
  iss="http://localhost:8180/realms/demo" azp="demo-cli" aud=(absent)
  realm_access={"roles":["user"]} resource_access=(absent)
  scope="profile email"
user=alice client=demo-cli-aud
  iss="http://localhost:8180/realms/demo" azp="demo-cli-aud" aud="spring-api"
  realm_access={"roles":["user"]} resource_access=(absent)
  scope="profile email"
user=bob client=demo-cli
  iss="http://localhost:8180/realms/demo" azp="demo-cli" aud="spring-api"
  realm_access={"roles":["admin","user"]} resource_access={"spring-api":{"roles":["reports-write"]}}
  scope="profile email"
service account admin-svc (client credentials)
  preferred_username="service-account-admin-svc" resource_access={"realm-management":{"roles":["view-realm","manage-users","view-users","query-groups","query-users"]}}
same login through http://127.0.0.1:8180 instead of localhost
  iss="http://127.0.0.1:8180/realms/demo"

Source: cap/01-claims.txt, produced by KeycloakIntegrationTest.java

Four things to read from it:

  • There is no roles claim. Realm roles are under realm_access.roles, client roles under resource_access.<client-id>.roles. Nothing Spring does by default looks there.
  • Alice’s token from demo-cli has no audience. Bob’s token from the very same client has aud="spring-api". In this run the difference follows from the roles: Bob holds a client role of spring-api and Alice does not, and Keycloak’s default behaviour adds an audience for clients whose roles end up in the token. I did not read that rule in the documentation; it is what these two tokens show. Either way, the practical lesson is that a missing audience is not a “never” but a “depends on the user”. The explicit audience mapper on demo-cli-aud makes it unconditional.
  • The service account has only realm-management roles, which is what lets it call the admin API and nothing else (section 6).
  • The iss claim contains the host the client used. Logging in through 127.0.0.1 gave http://127.0.0.1:8180/realms/demo. In start-dev, Keycloak derives it from the request. In production the hostname configuration fixes it; this post did not test that mode.

3. Mapping Keycloak roles to Spring authorities

The converter below turns realm_access.roles into ROLE_<role> and a client’s roles into ROLE_<client>:<role>, so a realm role and a client role with the same name cannot be confused. It keeps the default SCOPE_ authorities as well, and uses preferred_username as the principal name.

public static JwtAuthenticationConverter create(String resourceClient) {
    JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
    Converter<Jwt, Collection<GrantedAuthority>> all = jwt -> {
        List<GrantedAuthority> authorities = new ArrayList<>(scopes.convert(jwt));
        authorities.addAll(roles(jwt, resourceClient));
        return authorities;
    };
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(all);
    converter.setPrincipalClaimName("preferred_username");
    return converter;
}

public static List<GrantedAuthority> roles(Jwt jwt, String resourceClient) {
    List<GrantedAuthority> out = new ArrayList<>();
    Map<String, Object> realmAccess = jwt.getClaim("realm_access");
    if (realmAccess != null && realmAccess.get("roles") instanceof Collection<?> realmRoles) {
        realmRoles.forEach(r -> out.add(new SimpleGrantedAuthority("ROLE_" + r)));
    }
    Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
    if (resourceAccess != null && resourceAccess.get(resourceClient) instanceof Map<?, ?> client
            && client.get("roles") instanceof Collection<?> clientRoles) {
        clientRoles.forEach(r -> out.add(new SimpleGrantedAuthority("ROLE_" + resourceClient + ":" + r)));
    }
    return out;
}

Source: KeycloakJwtConverter.java

To see the difference, the test decodes the same real tokens and runs both Spring’s default converter and this one on them:

alice: Spring's default JwtGrantedAuthoritiesConverter -> [SCOPE_email, SCOPE_profile]
alice: KeycloakJwtConverter roles                      -> [ROLE_user]
bob: Spring's default JwtGrantedAuthoritiesConverter -> [SCOPE_email, SCOPE_profile]
bob: KeycloakJwtConverter roles                      -> [ROLE_admin, ROLE_spring-api:reports-write, ROLE_user]

Source: cap/02-authorities.txt

With the default converter, Bob, who holds both roles, would have no authority a role check could match. This is the most common Keycloak and Spring problem, and it fails closed: users are denied rather than let in, which is why it tends to be noticed as “everything returns 403”. For JWT handling in general, the JWT authentication guide and the resource server and key rotation post cover the parts this one skips.

4. The resource server

The configuration is the issuer, which Spring Boot uses to find Keycloak’s keys and to check the iss claim, and the audience the API requires. The audiences property adds an audience check to the decoder that Spring Boot builds.

keycloak:
  url: ${KEYCLOAK_URL:http://localhost:8180}
  realm: demo

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # The issuer must match the token's iss claim character for character, host included.
          issuer-uri: ${keycloak.url}/realms/${keycloak.realm}
          # Rejects tokens whose aud claim does not contain this value.
          audiences: spring-api

Source: application.yml

The filter chain applies the converter from section 3, requires the admin role for /api/admin/** and authentication for the rest of /api/**, and denies everything else. It is stateless, which is why CSRF protection is switched off: there is no session cookie to forge a request with. If you also serve a browser UI with cookies from the same application, that reasoning does not hold for those routes (see the CORS, CSRF and SameSite post).

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    static final String RESOURCE_CLIENT = "spring-api";

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(a -> a
                .requestMatchers("/api/admin/**").hasRole("admin")
                .requestMatchers("/api/**").authenticated()
                .anyRequest().denyAll())
            .oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(KeycloakJwtConverter.create(RESOURCE_CLIENT))))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .cors(Customizer.withDefaults());
        return http.build();
    }
}

Source: SecurityConfig.java

Fine-grained rules sit on the methods with @PreAuthorize. The report endpoint needs the realm role user, and the export endpoint needs the client role, spelled the way the converter produced it:

@GetMapping("/api/reports")
@PreAuthorize("hasRole('user')")
public List<String> reports() { return List.of("q1", "q2"); }

/** Needs the CLIENT role reports-write of the spring-api client, not a realm role. */
@GetMapping("/api/reports/export")
@PreAuthorize("hasRole('spring-api:reports-write')")
public String export() { return "exported"; }

Source: ApiController.java. Method security and its proxy pitfalls are in the method security post.

5. Who can call what

The test logs in as several callers and calls four endpoints with each. Every number below is an HTTP status from the running application, validating tokens issued by the real Keycloak:

caller                                   /api/me  /api/reports  /api/reports/export  /api/admin/users
no token                                     401           401                  401               401
garbage token                                401           401                  401               401
alice, client with audience mapper           200           200                  403               403
bob,   client with audience mapper           200           200                  200               200
alice, client WITHOUT audience mapper        401           401                  401               401
bob,   client WITHOUT audience mapper        200           200                  200               200
alice, token issued via 127.0.0.1            401           401                  401               401

Source: cap/03-endpoints.txt

Row by row:

  • No token, garbage token: 401 everywhere, as expected. The API does not accept it.
  • Alice with the audience mapper: in (200) for /api/me and /api/reports, but 403 for the export (she lacks the client role) and for the admin endpoint (she lacks the realm role). The token is accepted; the action is not allowed.
  • Bob with the audience mapper: 200 on all four.
  • Alice without the mapper: 401 on all four, although her token is validly signed and unexpired. Her token has no aud, so the audience check rejects it. That is the audience trap from section 2 in action.
  • Bob without the mapper: 200, because his token happens to carry aud="spring-api". The same client, the same API, and a different outcome: the dangerous kind of bug, because testing with Bob would never reveal it.
  • Alice through 127.0.0.1: 401 for every endpoint. The token is genuine, but its iss is http://127.0.0.1:8180/realms/demo and the API expects http://localhost:8180/realms/demo. This is the usual cause of “works in Postman, fails from the other container” when the public and internal Keycloak URLs differ.

The response body for those 401s says nothing useful, and the test did not capture the WWW-Authenticate header, so I cannot say here what reason it gives. When you debug one, decode the token and compare iss and aud against the configuration first.

Going deeper: audience validation, and why it is worth the trouble

Without an audience check, any valid token from the realm is accepted by this API, including tokens that were issued for a different application. A token meant for the “billing” client would open the “reports” API as long as the user’s roles matched. The audience claim is how a token says which API it is meant for, and checking it is what stops that. The price is that you must make Keycloak emit the claim reliably. The audience mapper in the realm file does that for every token from demo-cli-aud; relying on Keycloak’s default behaviour does not, as Alice’s token shows.

The same reasoning applies to the issuer. Strictly requiring one exact issuer string is inconvenient when the same Keycloak is reachable by two names, but accepting several issuers means trusting several sets of keys. The safe fix is to make Keycloak report one fixed public hostname, and have every client log in through it.

6. Managing users with the admin client

An application sometimes needs to create or disable users itself. The keycloak-admin-client library wraps Keycloak’s admin REST API. The important design choice is whose credentials it uses. Here the service uses the admin-svc service account (client credentials grant), which has only manage-users, view-users and view-realm. It does not forward the caller’s token, and it does not use a realm administrator’s password. The application’s own check on who may trigger these calls is @PreAuthorize("hasRole('admin')") on each method:

this.keycloak = KeycloakBuilder.builder()
        .serverUrl(url)
        .realm(realm)
        .grantType(OAuth2Constants.CLIENT_CREDENTIALS)
        .clientId(clientId)
        .clientSecret(secret)
        .build();

@PreAuthorize("hasRole('admin')")
public String create(String username, String password) {
    UserRepresentation user = new UserRepresentation();
    user.setUsername(username);
    user.setEmail(username + "@example.com");
    user.setEmailVerified(true);
    user.setFirstName(username);
    user.setLastName("Created");
    user.setEnabled(true);
    String id;
    try (Response response = keycloak.realm(realm).users().create(user)) {
        id = CreatedResponseUtil.getCreatedId(response);
    }
    CredentialRepresentation credential = new CredentialRepresentation();
    credential.setType(CredentialRepresentation.PASSWORD);
    credential.setValue(password);
    credential.setTemporary(false);
    keycloak.realm(realm).users().get(id).resetPassword(credential);

    RoleRepresentation role = keycloak.realm(realm).roles().get("user").toRepresentation();
    keycloak.realm(realm).users().get(id).roles().realmLevel().add(List.of(role));
    return id;
}

Source: UserAdminService.java

The test plays the whole lifecycle: Bob (an admin) lists users, creates carol, Carol logs in and calls the API, Bob deletes her, and Alice (not an admin) tries to create a user:

GET /api/admin/users as bob -> ["alice","bob"]
POST /api/admin/users {carol} as bob -> status=200 (id returned: true)
GET /api/admin/users as bob -> ["alice","bob","carol"]
carol logs in; realm_access={"roles":["offline_access","default-roles-demo","uma_authorization","user"]}
carol -> /api/reports status=200, /api/admin/users status=403
DELETE /api/admin/users/{id} as bob -> status=200
GET /api/admin/users as bob -> ["alice","bob"]
POST /api/admin/users as alice (no admin role) -> status=403

Source: cap/04-admin-client.txt

Two details in that output. Carol, created through the API, has the realm’s default roles (offline_access, default-roles-demo, uma_authorization) next to user, while the users imported from the realm file only had the roles listed there. If your authorization logic assumes a short, known list of roles, it has to cope with the extras. And Carol could read reports (200) but not reach the admin endpoint (403), so the role assignment did what it should.

Going deeper: admin client versions, and what was and was not exercised

The admin client on Maven Central is 26.0.12 (the latest release listed at the time of writing), while the server is 26.8.1. Every call shown above worked across that gap, and the library ran on a Spring Boot 4 classpath, alongside two Jackson generations (mvn dependency:tree shows Jackson 3.1.5 for Spring, and Jackson 2.21.5 plus RESTEasy 6.2.15 pulled in by the admin client) without a conflict in the calls I made. I tested only the calls in this post: listing, creating, setting a password, assigning a realm role, and deleting. I did not test other parts of the admin API, so check the ones you use if you pin different versions.

The admin client is also a remote call per operation, and create above makes four of them (create, set password, read role, assign role). They are not transactional: if the third fails, Carol exists without a password or role. Production code should handle that, for example by deleting the half-created user, or by defining the default role in the realm so it need not be assigned separately.

7. The same test in a container

When Docker is available, the usual approach is to start Keycloak in a container and import the same realm. The project includes that variant using testcontainers-keycloak 4.4.0:

@Testcontainers(disabledWithoutDocker = true)
class KeycloakTestcontainersTest {

    @Container
    static final KeycloakContainer KEYCLOAK = new KeycloakContainer("quay.io/keycloak/keycloak:26.8.1")
            .withRealmImportFile("demo-realm.json");

    @Test
    void realmIsImportedAndServesDiscovery() throws Exception {
        HttpResponse<String> r = Kc.HTTP.send(HttpRequest.newBuilder(URI.create(
                KEYCLOAK.getAuthServerUrl() + "/realms/demo/.well-known/openid-configuration")).build(), HttpResponse.BodyHandlers.ofString());
        assertThat(r.statusCode()).isEqualTo(200);
    }
}

Source: KeycloakTestcontainersTest.java

On the build machine this class compiled and the test was skipped, because there was no Docker (surefire reported 1 skipped). It has not been run, so I cannot vouch that the import file or the container version behave as written. What it would add over the distribution run is the exact image you deploy; what it would not change are the traps in sections 2 and 5, which come from token contents, not from how Keycloak is started.

Should you even do this? Running Keycloak is running an identity system: upgrades, a real database, TLS, a fixed hostname, backups, and its own security patches. If all you need is “log in with Google and read the roles”, a hosted identity provider is less to operate. If you want an authorization server that is just another Spring application you own, see Spring Authorization Server. Keycloak earns its place when you need its features (user federation, social login brokering, an admin console, fine-grained roles) and can afford to operate it. Also note the limits of this post’s evidence: everything ran against Keycloak 26.8.1 in start-dev mode on one machine, with the password grant for convenience, and nothing was tested over TLS, with multiple Keycloak nodes, or under load.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.