Skip to main content

Command Palette

Search for a command to run...

Reasons Your API Returns 403 With a Valid Token — and How to Fix Missing Role Mapping

You finally get JWT verification green across the board, the signature checks out, the issuer is legit, and yet the API responds with a stubborn 403. No stack trace fireworks, no smoking gun—just Access Denied. If that feels like being carded at ...

Published
9 min read
Reasons Your API Returns 403 With a Valid Token — and How to Fix Missing Role Mapping
T

I am Tuanh.net. As of 2024, I have accumulated 8 years of experience in backend programming. I am delighted to connect and share my knowledge with everyone.

1. What “Valid Token but 403” Actually Means

When Spring Security (or any resource server) returns 403 with a verified token, it’s saying: “I believe you are who you say you are; I just don’t think you’re allowed in here.” That decision hinges on granted authorities, which are produced by converting token claims (like roles, realm_access.roles, scope, or permissions) into Spring’s GrantedAuthority set. If that conversion yields an empty or mismatched set, any hasRole, hasAuthority, or @PreAuthorize guard will block you.

The most common sources of mismatch are surprisingly mundane: your token puts roles under realm_access.roles while your converter looks at scope; your app expects ROLE_ADMIN but your token only carries admin; your gateway strips authorities on forward; or your method security and URL security disagree on what the role should be called. The result is a clean 403 because the framework did exactly what you asked—just not what you intended.

1.1 The Three Places Where Role Mapping Can Go Sideways

The pipeline has three choke points, and chasing the bug without naming them is how we all end up logging at TRACE on Friday night.

First is the token content itself. If your identity provider puts roles in a place your app never checks, you’ll authenticate but never authorize. Keycloak, for instance, may place roles under realm_access.roles or resource_access.my-client.roles. Okta or Cognito may put permissions into scope. Your resource server isn’t magic—it needs explicit mapping logic.

Second is the resource server mapping logic. In Spring Security for JWT, the JwtAuthenticationConverter (plus a nested Converter > ) turns claims into GrantedAuthority. If that converter returns nothing, you’re effectively role-less royalty.

Third is your access rules. hasRole("ADMIN") expects an authority literally named ROLE_ADMIN. If the converter returns admin or SCOPE_admin, your checks will never match. Similarly, URL rules and method-level rules must agree: if endpoints use hasAuthority("SCOPE_read") but your methods use @PreAuthorize("hasRole('READER')"), one of them will 403.

1.2 A Minimal, Correct-By-Design Spring Boot 3 Example

Below is a focused Java example that fixes 403s caused by missing or mismatched role mapping. It supports multiple claim shapes: realm_access.roles, resourceaccess.{client}.roles, roles, and scope/scp. It also normalizes to ROLE and SCOPE_ so both hasRole and hasAuthority can work.

// build.gradle (or pom.xml equivalent)
// implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
// implementation("org.springframework.boot:spring-boot-starter-security")

package com.example.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authorization.AuthorityAuthorizationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;

import java.util.;
import java.util.stream.Collectors;

@Configuration
@EnableMethodSecurity // enables @PreAuthorize
public class SecurityConfig {

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/actuator/health").permitAll()
// Example: require ADMIN role for admin APIs
.requestMatchers("/admin/").access(AuthorityAuthorizationManager.hasRole("ADMIN"))
// Example: require 'read' scope for reads
.requestMatchers(HttpMethod.GET, "/api/
").hasAuthority("SCOPE_read")
// Everything else requires authentication
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
);
return http.build();
}

/**
Converts JWT -> Authentication with properly mapped authorities.
Supports:
- realm_access.roles (Keycloak realm roles)
- resource_access.{client}.roles (Keycloak client roles)
- roles (generic array)
- scope/scp (OAuth scopes)
/
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(this::extractAuthorities);
return converter;
}

private Collection<grantedauthority> extractAuthorities(Jwt jwt) {
Set<string> roleNames = new LinkedHashSet<>();

// 1) Keycloak realm roles: { "realm_access": { "roles": ["admin","user"] } }
Map<string, object=""> realmAccess = jwt.getClaim("realm_access");
if (realmAccess instanceof Map<!--?, ?--> ra) {
Object roles = ra.get("roles");
if (roles instanceof Collection<!--?--> c) {
c.stream().map(Object::toString).forEach(roleNames::add);
}
}

// 2) Keycloak client roles: { "resource_access": { "<clientid>": { "roles": [...] } } }
Map<string, object=""> resourceAccess = jwt.getClaim("resourceaccess");
if (resourceAccess instanceof Map<!--?, ?--> res) {
res.values().forEach(v -> {
if (v instanceof Map<!--?, ?--> clientObj) {
Object roles = clientObj.get("roles");
if (roles instanceof Collection<!--?--> c) {
c.stream().map(Object::toString).forEach(roleNames::add);
}
}
});
}

// 3) Generic roles array: { "roles": ["manager"] }
Object rolesClaim = jwt.getClaim("roles");
if (rolesClaim instanceof Collection<!--?--> c) {
c.stream().map(Object::toString).forEach(roleNames::add);
}

// 4) Scopes: "scope": "read write" or "scp": ["read","write"]
String scope = jwt.getClaimAsString("scope");
if (scope != null) {
Arrays.stream(scope.split("\s+")).forEach(s -> roleNames.add("SCOPE
" + s));
}
Object scp = jwt.getClaim("scp");
if (scp instanceof Collection<!--?--> c) {
c.stream().map(Object::toString).forEach(s -> roleNames.add("SCOPE" + s));
}

// Normalize to ROLE
* when the claim looks like a role
Set<string> normalized = roleNames.stream()
.flatMap(name -> {
if (name.startsWith("SCOPE")) return Set.of(name).stream();
String n = name.toUpperCase(Locale.ROOT);
return Set.of(n.startsWith("ROLE
") ? n : "ROLE_" + n).stream();
})
.collect(Collectors.toCollection(LinkedHashSet::new));

// Optional: fallback for authenticated-but-roleless users
if (normalized.isEmpty()) {
// Give them a minimal identity authority if you want to distinguish later
normalized.add("ROLE_AUTHENTICATED");
}

return normalized.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
}

The flow is boring by design, which is exactly how security should be. The JwtAuthenticationConverter delegates to extractAuthorities, which hunts across popular claim shapes and converts them into Spring-friendly GrantedAuthoritys. If your @PreAuthorize("hasRole('ADMIN')") expects ROLE_ADMIN, that’s precisely what the converter emits. If you guard endpoints by scope, the same token yields SCOPE_read authorities, too.

1.3 Proving It Works (and Why Your Old Setup 403’d)

With the configuration above:

// Example controller
package com.example.api;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/admin")
public class AdminController {

@GetMapping("/whoami")
@PreAuthorize("hasRole('ADMIN')")
public String whoAmI() {
return "You are an ADMIN, welcome to the control room.";
}
}

@RestController
@RequestMapping("/api")
class ApiController {
@GetMapping("/docs")
@PreAuthorize("hasAuthority('SCOPE_read')")
public String docs() {
return "Readable content for tokens with 'read' scope.";
}
}

If your JWT carries realm_access.roles = ["admin"], you now get ROLE_ADMIN and the call to /admin/whoami returns 200. If it carries scope=read write, you get SCOPE_read and SCOPE_write, and /api/docs works. The identical token would have produced 403 before, because nothing translated your claims into the strings Spring’s access checks actually compare.

2. Deep-Dive Explanations, Diagnostics, and Hardening Techniques

Security bugs love ambiguity. The more explicit you make claim paths, expected authority prefixes, and access checks, the fewer 403 mysteries you’ll have to debug later.

First, validate the claim source. Decide whether you’ll trust realm roles, client roles, or scopes. If you use Keycloak client roles for multi-tenant apps, consider requiring resource_access.{clientId}.roles and ignoring realm roles entirely. That prevents a realm-level role from accidentally opening doors it shouldn’t.

Second, normalize consistently. Pick ROLE* for role-like permissions and SCOPE* for OAuth scopes. Don’t compare raw claim strings in annotations; always compare normalized authorities, because annotations are matched against GrantedAuthority#getAuthority().

Third, log just enough. At DEBUG, log the final authority set after conversion (never log the full token). That single line saves hours: “Authorities for sub=abc…: [ROLE_ADMIN, SCOPE_read]”. If that array is empty, you know the converter never saw what it expected.

2.1 Typical Root Causes of “Valid Token, 403” and How to Fix Each

A frequent offender is claim path mismatch. Your IDP shipped roles under resource_access.app.roles, but your converter looks at realm_access.roles. Fix by inspecting a real token (JWT debugger locally, not in logs) and aligning your converter with the actual claim path.

Another culprit is prefix mismatch. hasRole('ADMIN') expands internally to check for ROLEADMIN. If your converter yields ADMIN or your gateway stripped the prefix, you’ll see a clean 403. Always normalize with ROLE for role-like items.

A third is method vs. URL guard drift. You might have http.authorizeHttpRequests().requestMatchers("/admin/**").hasRole("ADMIN") and simultaneously annotate controllers with @PreAuthorize("hasAuthority('SCOPE_admin')"). Either is fine, but they must agree on what “admin” means.

Finally, tenant-aware clients often put permissions under resource_access.{clientId}.roles. If you rotate the client ID and forget to update the converter to look up the new node, all roles vanish overnight.

2.2 Production-Grade Hardening, Testing, and Operability

The snazziest role mapping won’t help if deployments can regress silently. A low-effort, high-payoff step is contract testing for JWT claims. Keep one or two golden tokens (generated in lower envs with non-sensitive keys) and run an integration test that boots Spring and asserts:

  • /admin/whoami returns 200 with the “admin” golden token and 403 with the “reader” golden token.
  • /api/docs returns 200 with the “read” token and 403 with a token lacking read.

This catches misconfigurations the moment someone renames a claim or changes the client ID.

For observability, expose a narrow, non-sensitive metric: count of 403 by endpoint. If a release silently zeros-out authorities, your 403 rate will spike and you’ll catch it in minutes. Pair that with a DEBUG log of the final authority set during canary deployments, then turn it off once confident.

Lastly, fail closed but explain clearly. Spring correctly returns 403, but you can add an exception handler to emit a precise reason in non-prod:

package com.example.security;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
class AccessDeniedAdvice {

@ExceptionHandler(AccessDeniedException.class)
ResponseEntity<string> onDenied(HttpServletRequest req, AccessDeniedException ex) {
// In production, keep this terse; avoid leaking internals.
String msg = "Access denied for " + req.getMethod() + " " + req.getRequestURI()
+ ". Your token is valid but lacks required authorities.";
return ResponseEntity.status(403).body(msg);
}
}

That message won’t fix a broken mapping, but it immediately tells your team what class of bug they’re chasing.

3. Secrets to Making Role Mapping Boring (a.k.a. Reliable)

The real “secret” is to eliminate surprises. Pick a single source of truth for permissions, write a converter that normalizes mercilessly, and keep your annotations consistent with that normalization. Boring security is secure security.

If your organization uses multiple identity providers or token shapes, wrap the conversion in small, well-named helpers—extractRealmRoles, extractClientRoles, extractScopes—and feature-flag the active strategy per environment. When a new client rolls out with permissions instead of roles, you add one helper and one test, then go back to sipping your coffee like authorization royalty.

As a final touch, make a “role decoder” endpoint behind admin auth that returns the authorities seen for the current token. It’s the friendliest debugging surface you can offer your SREs and app developers.

3.1 Bonus: If You’re Behind an API Gateway

If a gateway injects X-Authorization or rewrites headers, make sure it forwards the original token and does not clobber your app’s Authorization header. Some gateways can validate JWTs and pass a smaller user context downstream; if you rely on that, update your converter to read whatever header or claim mapping the gateway emits. Consistency beats cleverness here.

3.2 Quick Field Checklist Before You Ship

Open a real token and confirm, one by one:

  • Which claim actually contains the permissions you mean to enforce (realm_access.roles, resource_access.{client}.roles, roles, scope/scp)?
  • Do your guards expect hasRole('X') or hasAuthority('SCOPEx')?
  • Does your converter guarantee the ROLE and SCOPE_ prefixes accordingly?
  • Do method and URL guards agree on the same vocabulary?

If you can answer those without guessing, your 403s will become intentional, not accidental.

3.3 Closing Thought

A 401 says “who are you?”, a 403 says “not for you.” When your token is valid but still earns a 403, the villain is almost always role mapping. Teach your app to read the token like a human does, normalize everything, and your authorization layer will stop being the mystery box on your incident board.

Have questions, edge cases, or a gnarly token you want decoded? Drop a comment below and let’s troubleshoot together.

Read more at : Reasons Your API Returns 403 With a Valid Token — and How to Fix Missing Role Mapping

More from this blog

T

tuanh.net

540 posts

Are you ready to elevate your Java, OOP, Spring, and DevOps skills? Look no further!