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 ...

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
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.
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
TRACE on Friday night.
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.
JwtAuthenticationConverter (plus a nested Converter
>
) turns claims into GrantedAuthority. If that converter returns nothing, you’re effectively role-less royalty.
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
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());
}
}
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)
// 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.";
}
}
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
resource_access.{clientId}.roles and ignoring realm roles entirely. That prevents a realm-level role from accidentally opening doors it shouldn’t.
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().
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
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.
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.
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.
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
/admin/whoamireturns 200 with the “admin” golden token and 403 with the “reader” golden token./api/docsreturns 200 with the “read” token and 403 with a token lackingread.
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);
}
}
3. Secrets to Making Role Mapping Boring (a.k.a. Reliable)
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.
3.1 Bonus: If You’re Behind an API Gateway
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
- 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')orhasAuthority('SCOPEx')? - Does your converter guarantee the
ROLEandSCOPE_prefixes accordingly? - Do method and URL guards agree on the same vocabulary?
3.3 Closing Thought
Read more at : Reasons Your API Returns 403 With a Valid Token — and How to Fix Missing Role Mapping





