← Back to the section

Keycloak has checked the password and issued the user a token — great. But then an awkward question comes up: what is this user actually allowed to do? Can they delete someone else's orders? See the admin panel? The token by itself forbids nothing — it only says who showed up and with which roles. Turning "you have a role" into "you can't come in here" is already your application's job, and Keycloak won't do it for you.

Let's work through it from scratch, step by step: where roles come from, how they get into the token, how Spring pulls them out, and at what moment the "allow or not" decision is made.

Authentication and authorization are different things

Two words that get confused constantly, yet they are about completely different things.

  • Authentication is the question "who are you?". The user entered a login and password, Keycloak recognized them and issued a token. That's where authentication ends — identity is established.
  • Authorization is the question "what are you allowed to do?". This is already about access to specific actions: create an order, view someone else's profile, enter the admin area.

An everyday analogy. Authentication is the guard at the building entrance who matched your face against your pass and let you inside. Authorization is the locks on the office doors inside: the pass opened the turnstile for you, but not every door. You can be let into the building (authenticated) and still run into a locked door of the office you need (not authorized).

Keycloak is responsible for the first part — it recognizes the user and puts their roles into the token. The second part — "with these roles you may go here, but not there" — is decided by your application. The rest of this article is precisely about that.

Where roles come from in the first place

A role is just a label that a Keycloak administrator attaches to a user: customer, admin, order-manager. The label by itself means nothing — it's a string. Meaning is given to it by your application, when it says "whoever has the admin label, let them into the admin area".

Keycloak distinguishes two kinds of roles, and this difference matters, because they later live in different places in the token.

  • Realm role — a role at the level of the whole realm. A realm is one isolated "kingdom" of users in Keycloak. A realm role is visible to all applications of that realm. This is where you put general labels like customer or admin — the ones that make sense across the whole system.
  • Client role — a role tied to a specific client (application) registered in Keycloak. Such a label makes sense only inside one application. For example, order-manager is meaningful only in the order service and isn't needed by the rest.

In a nutshell: a realm role is "they're our employee in general", a client role is "they're a manager specifically in this application".

How a role reaches the access check: the full path

Before diving into details, let's look at the whole path from above — from an assigned role to the "allow or not" decision. This is the main storyline of the article, and we'll examine each step separately afterward.

What's on the diagram: an administrator assigns a role to a user, it travels inside the token as a claim, your service pulls it out and turns it into a form Spring understands, and only at the end is a decision made based on it.

diagram

Briefly, step by step: the administrator attached a role → the user logged in and the role got into the token → the token arrived at your service with a request → the service pulled the roles from the right place in the token → translated them into its internal format → matched them against the access rule. Now — each step in detail.

Step 1: where roles live inside the token

A token from Keycloak (more precisely — the access_token, the very one that arrives at the API in the Authorization: Bearer ... header) is a JWT (JSON Web Token). Essentially it's a JSON object signed by the server. Inside are claims — fields with information about the user. If you decode the token (for example, at jwt.io — it shows the contents of any JWT), the roles are visible right inside.

Remember the two kinds of roles? Keycloak puts them in different places in the token — this is the key fact people trip over most often.

{
  "sub": "a1b2c3d4-...",
  "preferred_username": "ivan",
  "realm_access": {
    "roles": ["customer", "premium"]
  },
  "resource_access": {
    "orders-service": {
      "roles": ["order-manager"]
    }
  },
  "scope": "openid profile email"
}

What's what here:

  • realm_access.roles — this is where the realm roles live. It's a flat list of labels at the whole-realm level. In the example, the user ivan has the realm roles customer and premium.
  • resource_access.<client>.roles — this is where the client roles live, grouped by client name. In the example, under the key orders-service lies the client role order-manager. If the user had client roles in another application, another key with that application's name would appear under resource_access.
  • scope — this isn't about the user's roles at all. It's about what the application itself is allowed to do while acting on the user's behalf. We'll come back to it separately below.
  • sub — a stable unique identifier of the user in Keycloak. It comes in handy in ABAC, to figure out who exactly showed up.

The main practical takeaway: realm roles and client roles live under different keys. If your application looks for roles only in realm_access, it will never see client roles — and vice versa. And since it didn't see them, it will decide the user has no role and deny access where access should be granted.

Step 2: why roles need to be "translated" — GrantedAuthority

Here it's important to understand one thing: Spring Security knows nothing about Keycloak. To it, realm_access.roles is just some unfamiliar field in JSON. Internally, Spring operates on its own notion — the GrantedAuthority ("a granted right"). It's just a string label like ROLE_admin or SCOPE_profile, which Spring will later match against access rules.

The problem: Spring out of the box cannot pull roles from realm_access.roles — it's a format specific to Keycloak, not part of a standard. By default, Spring looks only at the scope claim and turns it into an authority with a SCOPE_ prefix. Realm and client roles it simply ignores — as if they don't exist.

That means between the token and Spring you need a translator: a component that takes the roles from the right places in the token and turns each one into a GrantedAuthority. In Spring Security this translator is called JwtAuthenticationConverter.

An analogy. The token is a passport written in a foreign language (in "Keycloak language"). GrantedAuthority is the entries in a form written in a language Spring understands. JwtAuthenticationConverter is the translator who reads the passport and fills in Spring's internal form. Without the translator, Spring looks at the passport and sees unintelligible letters.

Step 3: configuring the role translator

First — the minimal resource server setup, that is, your application that accepts and validates tokens. In application.yml we specify whom to trust:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/myrealm

From the issuer-uri, Spring will itself find the JWKS (the set of Keycloak's public keys) and will verify the signature of every incoming token. This is critical: the signature guarantees the roles in the token are genuine and not forged. The check works automatically, keys are cached — you don't need to write anything here.

Now the translator itself. We pull the roles from realm_access.roles and attach a ROLE_ prefix to each (why exactly ROLE_ — in the next section):

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    var converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwt -> {
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        if (realmAccess == null) {
            return List.of();
        }
        @SuppressWarnings("unchecked")
        List<String> roles = (List<String>) realmAccess.get("roles");
        if (roles == null) {
            return List.<GrantedAuthority>of();
        }
        return roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .collect(Collectors.toList());
    });
    return converter;
}

After this, the role customer from the token turns into the authority ROLE_customer, which Spring already understands and can match against rules.

Note: this example pulls only realm roles. If you also need client roles, in the same converter you'll have to also look into resource_access.<client-name>.roles and add them to the common list — otherwise they'll be lost.

Step 4: the main source of confusion — the ROLE_ prefix

In Spring there are two very similar ways to check a right, and the difference between them catches almost every newcomer.

  • hasRole('customer') — Spring silently adds the ROLE_ prefix itself and actually looks for the authority ROLE_customer.
  • hasAuthority('ROLE_customer') — Spring looks for the authority exactly as written, adding nothing.

In other words, hasRole('customer') and hasAuthority('ROLE_customer') are the same thing. But hasRole('ROLE_customer') turns into a search for ROLE_ROLE_customer (a double prefix) and will never work.

From this comes a simple rule of choice when mapping:

  • if in the converter you added ROLE_ to the roles (as in the example above) — use hasRole('customer'), without the prefix in the argument;
  • if you didn't add it and the authority is called just customer — then only hasAuthority('customer'), because hasRole('customer') will look for a non-existent ROLE_customer and silently deny.

The most common mistake in the whole topic of access: roles were mapped without a prefix, but the code says hasRole('admin'). Access silently doesn't work — Spring looks for ROLE_admin, while the authority holds just admin, and they don't match. There's no error in the logs at all, just a 403. To avoid tripping over this, it's more convenient to add ROLE_ once in the converter and write hasRole everywhere.

RBAC: access by roles

Now that roles have reached Spring as authorities, we can use them. The simplest and most common approach is called RBAC (Role-Based Access Control) — access by roles. The logic is straightforward: "you have the admin role → let you into the admin area". No additional conditions, just the presence of the right label.

In Spring this is set with the @PreAuthorize annotation right above the controller method:

@RestController
class OrderController {

    @PreAuthorize("hasRole('admin')")
    @DeleteMapping("/orders/{id}")
    void deleteOrder(@PathVariable Long id) { ... }

    @PreAuthorize("hasRole('customer')")
    @PostMapping("/orders")
    void createOrder(@RequestBody OrderRequest req) { ... }
}

An important nuance that's easy to forget: for @PreAuthorize to do anything at all, method-level security must be explicitly enabled:

@Configuration
@EnableMethodSecurity
class SecurityConfig { ... }

Without @EnableMethodSecurity, the @PreAuthorize annotations are simply ignored — Spring doesn't see them, the method is always invoked, and it looks to you like protection is in place. This is an insidious mistake: the code looks protected, but in reality it's a hole.

RBAC answers the question "which type of user is allowed here at all?". It's control at the level of an action (an endpoint), not at the level of a specific record. RBAC will easily say "any customer can edit orders" — but it fundamentally can't check whether the order is theirs. That's exactly what the next approach is for.

ABAC: when one role isn't enough

Imagine a situation. Ivan has the customer role, and RBAC allows any customer to edit orders. Ivan opens Peter's order and changes the delivery address to his own. Ivan's role is correct — RBAC lets it through without questions. Yet the access is completely wrong: one user got into another's data.

Where's the root of the problem: a role knows nothing about a specific record. "Can edit orders" and "can edit this order" are two different questions, and a role answers only the first.

ABAC (Attribute-Based Access Control) — access by attributes. The decision is made not only by the role, but also by attributes: who owns the resource, who the user is, what status the record has. The most common and understandable case of ABAC is the ownership check: does the order's owner match the one who arrived in the token.

And who exactly arrived — we take from the sub claim of the token (that same stable user identifier):

@Service
class OrderService {

    void updateAddress(Long orderId, Address address, Jwt jwt) {
        Order order = repository.findById(orderId).orElseThrow();
        String currentUserId = jwt.getSubject();   // claim "sub"

        if (!order.getOwnerId().equals(currentUserId)) {
            throw new AccessDeniedException("not your order");
        }
        order.changeAddress(address);
    }
}

The key difference from RBAC: here the decision depends on data (who owns the specific order), not just on the presence of a role. Such a check is kept in one place — in a service or in a separate access component — and not smeared by copy-paste across controllers.

A common and reasonable compromise: the admin role bypasses the ownership check — an administrator is allowed to touch others' records (that's what makes them an admin). But every such action should be written to a log (audit), so that later it's visible who, when, and what changed in someone else's data.

RBAC and ABAC work together

In practice these two approaches are not opposed, but combined. RBAC filters coarsely and early, ABAC refines at the level of a specific record.

What's on the diagram: a request first passes through a coarse filter by role, and only if the role fit is the expensive record-owner check performed.

diagram

The order here isn't accidental, but for the sake of economy. The coarse filter by role is cheap (check a string in memory) and fires first — if there's no role, we deny immediately without touching the database. The expensive check (go to the database, load the record, compare the owner) is performed only when the role has already fit. This way we don't burden the database with requests that would fall away at the role level anyway.

Where scope comes in

Sometimes you need to check not the user's role, but the permission of the application itself — that same scope field from the token. The difference is subtle but important: a role answers the question "who the user is" (customer, admin), while a scope answers "what the application is allowed to do on the user's behalf" (read orders, but not delete them).

As we already found out, scope is the only thing Spring pulls from the token itself, without a converter. It turns each scope into an authority with a SCOPE_ prefix:

@PreAuthorize("hasAuthority('SCOPE_orders:read')")
@GetMapping("/orders")
List<Order> list() { ... }

When to use what, in simple terms: for ordinary business access inside your services, roles are almost always enough. Scope is more often needed in scenarios with external clients and public APIs, where it's important to limit exactly what the user consented to ("this application can read my orders, but not manage them").

How many roles to create in the first place

Since a role is just a label that's easy to create in Keycloak with a couple of clicks, there's a temptation to breed them for every little thing: customer, customer-premium, customer-trial, seller, seller-pro, partner-admin, junior-admin... Six months later the catalog has two dozen roles, no one remembers how seller-pro differs from seller, and access checks turn into a mush of hasAnyRole(...) half a screen long.

Healthy discipline is the opposite: there should be few and stable roles. A good catalog for a typical service fits into a handful, for example:

  • customer — an end user, creates and reads their own orders;
  • seller — a seller, manages their own products;
  • admin — an internal employee with extended access;
  • system — a service role for "service to service" calls.

And that's it. The appearance of a new role isn't routine, but a signal to stop and think: is this really a new type of user, or are we trying to express something else with a role?

Most often — something else. Let's look at typical cases when a "need a new role" isn't actually a role:

  • "Premium customers have access to extra features." Premium isn't a separate breed of people, but an attribute of an ordinary customer: they have an active subscription or not. It's the question "what property does this user have", not "who are they by role". This is solved the same way as the ownership check from the ABAC section — by looking at the user's data (whether there's an active subscription), not at the presence of a separate role. Creating a customer-premium role for the sake of a subscription means baking a business trait that will change tomorrow into an immovable access label.
  • "B2B customers shouldn't see retail." If two groups of users work with fundamentally different data and scenarios — that's most likely two different services (or two different areas within the system), not two roles in one.
  • "A junior-admin can only view, but not change." Here it's not about a new role, but about a set of permissions within the admin role. Splitting the administrator into a ladder of roles (admin, admin-readonly, admin-billing...) is a path to the same sprawl; usually this is solved with a permission system, not by multiplying roles.

A simple rule: a role answers the question "who is this user in general", while everything that sounds like "and they also have/don't have such a property" is an attribute, that is, ABAC territory, not a new line in the role catalog. The smaller and more stable the catalog, the clearer the access code and the smaller the chance that someone somewhere forgets to add yet another role to yet another check.

Every endpoint must have a check

This is probably the most important rule of the whole topic — and at the same time the one that's easiest to forget. It sounds simple: every controller method must have an explicit access check (@PreAuthorize). No exceptions.

Why so strict? Because a method without @PreAuthorize is open to any authenticated user. If the token is valid — Spring lets it through, and it doesn't matter what roles the person has. That is, a forgotten annotation isn't "access a bit wider than needed", but "access for everyone who has a token at all".

This is especially insidious with admin methods. It seems that an address like /admin/orders/{id}/refund protects something by itself — as in, "it's an admin path after all". It doesn't. A URL is just a string, it forbids nothing. If the refund method doesn't have @PreAuthorize("hasRole('admin')") above it, then a refund can be initiated by any token holder, including an ordinary customer. The hole is all the more dangerous because it looks harmless: the code seems to be in place, the path is "admin", but there's no check.

The problem is that a forgotten annotation isn't always caught by eye in review — there are many methods, one was missed, and that's it. So such a rule is convenient to check automatically, with a test. There's a library called ArchUnit — it can write tests not about the behavior of the code, but about its structure: "all controller methods must have such-and-such annotation". Write it once — and the build fails as soon as someone adds an endpoint without a check:

@ArchTest
static final ArchRule everyEndpointHasPreAuthorize =
    methods()
        .that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
        .and().areMetaAnnotatedWith(RequestMapping.class)
        .should().beAnnotatedWith(PreAuthorize.class)
        .because("an endpoint without an access check is open to anyone who has a token");

Here areMetaAnnotatedWith(RequestMapping.class) catches all variants at once — @GetMapping, @PostMapping, @PutMapping, @DeleteMapping (all of them are under the hood marked with @RequestMapping). Such a test turns the agreement "don't forget the check" into a guarantee: forgetting is now physically impossible — the build won't pass.

Common mistakes

Let's gather the rakes people step on most often — almost all of them have already come up earlier in the text.

  • Looking for roles in the wrong claim. Realm roles are in realm_access.roles, client roles are in resource_access.<client>.roles. If you read only realm_access, client roles are lost (and vice versa).
  • Forgot about the ROLE_ prefix. Mapped the role as admin, but the code has hasRole('admin') (Spring looks for ROLE_admin). Access silently doesn't work. Either add ROLE_ in the converter, or write hasAuthority.
  • Double prefix. hasRole('ROLE_admin') looks for ROLE_ROLE_admin. In hasRole you don't write the prefix — Spring adds it itself.
  • Relying on RBAC where ABAC is needed. The customer role doesn't guarantee the order is theirs. Without an ownership check, one user edits another's data.
  • Didn't enable @EnableMethodSecurity. Without it, the @PreAuthorize annotations are simply ignored — while it looks like protection is in place.
  • Trusting roles without verifying the signature. Roles in a JWT are plain text inside JSON. Without a signature check (via issuer-uri/JWKS) they're easy to forge. Never parse a token "by hand" bypassing the resource server — let Spring verify the signature.

In short

  • Authentication — "who you are" (Keycloak does this); authorization — "what you're allowed to do" (your application decides this by roles).
  • The full path of a role: the administrator assigned it → the role got into the token → the service pulled it from the token → translated it into a GrantedAuthority → matched it against an access rule.
  • Realm roles live in realm_access.roles, client roles in resource_access.<client>.roles, the application's permissions in scope. These are different places — look where you should.
  • Spring out of the box doesn't see Keycloak roles — you need a JwtAuthenticationConverter that maps them to GrantedAuthority.
  • hasRole('x') adds ROLE_ itself; hasAuthority('y') looks for the exact name. These are different things — hence most access mistakes.
  • RBAC — access by role via @PreAuthorize (+ necessarily @EnableMethodSecurity); control at the level of an action.
  • ABAC — access by attributes; most often it's an ownership check: compare the record's owner with sub from the token.
  • In practice, RBAC and ABAC are combined: the role filters early and cheaply, the ownership check refines at the level of a specific record.
  • Roles in a JWT are just text; you can trust them only after verifying the signature via issuer-uri/JWKS.
  • Realm, client, roles, and users in Keycloak — where roles come from and how they're configured in Keycloak.
  • Keycloak and Spring Security: verifying tokens — how a service accepts a token and verifies its signature against JWKS.
  • Keycloak tokens: verification, refresh, revocation, and errors — what a JWT is made of and what breaks most often.