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
customeroradmin— 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-manageris 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.
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 userivanhas the realm rolescustomerandpremium.resource_access.<client>.roles— this is where the client roles live, grouped by client name. In the example, under the keyorders-servicelies the client roleorder-manager. If the user had client roles in another application, another key with that application's name would appear underresource_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 theROLE_prefix itself and actually looks for the authorityROLE_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) — usehasRole('customer'), without the prefix in the argument; - if you didn't add it and the authority is called just
customer— then onlyhasAuthority('customer'), becausehasRole('customer')will look for a non-existentROLE_customerand 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.
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 acustomer-premiumrole 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
adminrole. 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 inresource_access.<client>.roles. If you read onlyrealm_access, client roles are lost (and vice versa). - Forgot about the
ROLE_prefix. Mapped the role asadmin, but the code hashasRole('admin')(Spring looks forROLE_admin). Access silently doesn't work. Either addROLE_in the converter, or writehasAuthority. - Double prefix.
hasRole('ROLE_admin')looks forROLE_ROLE_admin. InhasRoleyou don't write the prefix — Spring adds it itself. - Relying on RBAC where ABAC is needed. The
customerrole doesn't guarantee the order is theirs. Without an ownership check, one user edits another's data. - Didn't enable
@EnableMethodSecurity. Without it, the@PreAuthorizeannotations 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 inresource_access.<client>.roles, the application's permissions inscope. These are different places — look where you should. - Spring out of the box doesn't see Keycloak roles — you need a
JwtAuthenticationConverterthat maps them toGrantedAuthority. hasRole('x')addsROLE_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
subfrom 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.
What to read next
- 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.