Your authentication works.
The JWT is valid.
The user reaches a protected endpoint.
And Spring Security responds with:
403 Forbidden
One surprisingly common reason is a mismatch between:
hasRole("ADMIN")
and:
hasAuthority("ADMIN")
They look almost identical.
They aren't.
hasAuthority() checks the authority you give it
In Spring Security authorization, hasAuthority() checks whether the authenticated user has a matching GrantedAuthority.
If the user has:
ADMIN
then:
hasAuthority("ADMIN")
matches it.
For example:
new SimpleGrantedAuthority("ADMIN")
works with:
.requestMatchers("/admin/**")
.hasAuthority("ADMIN")
SimpleGrantedAuthority is Spring Security's standard way of representing a string-based authority.
hasRole() uses the role prefix
By default, Spring Security treats roles as authorities prefixed with:
ROLE_
So:
hasRole("ADMIN")
checks for:
ROLE_ADMIN
You do not pass the prefix yourself:
hasRole("ADMIN") // correct
not:
hasRole("ROLE_ADMIN")
Spring Security's authorization documentation describes hasRole() as a shortcut that applies the configured role prefix. The default is ROLE_.
So this:
new SimpleGrantedAuthority("ADMIN")
does not match:
hasRole("ADMIN")
But this does:
new SimpleGrantedAuthority("ROLE_ADMIN")
The bug in one example
Imagine your JWT contains:
{
"sub": "user@example.com",
"role": "ADMIN"
}
Your authentication code converts that role into:
new SimpleGrantedAuthority("ADMIN")
But your security configuration says:
.requestMatchers("/admin/**")
.hasRole("ADMIN")
Authentication can succeed.
Spring can recognize the user.
But authorization still fails because Spring is checking for:
ROLE_ADMIN
while the authenticated user has:
ADMIN
Result:
403 Forbidden
The JWT itself wasn't necessarily the problem.
The authority naming was.
Two valid approaches
Option 1: Use authorities directly
Store:
new SimpleGrantedAuthority("ADMIN")
and check:
hasAuthority("ADMIN")
Option 2: Follow Spring's role convention
Store:
new SimpleGrantedAuthority("ROLE_ADMIN")
and check:
hasRole("ADMIN")
Both approaches are valid.
The important part is consistency.
Spring Security also allows the default role prefix to be customized, so ROLE_ is the default convention rather than an unavoidable requirement.
You can read more in the official authorization architecture documentation.
The same thing applies to @PreAuthorize
The same rule applies to method security.
This:
@PreAuthorize("hasRole('ADMIN')")
checks for:
ROLE_ADMIN
while:
@PreAuthorize("hasAuthority('ADMIN')")
checks for:
ADMIN
Spring Security documents both expressions in its official method security guide.
So if request-level authorization works but a method protected with @PreAuthorize still returns a 403, check whether your authorities and expressions follow the same convention.
A useful debugging trick
When authorization doesn't behave as expected, check what authorities Spring actually has:
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
authentication.getAuthorities()
.forEach(System.out::println);
You may expect:
ROLE_ADMIN
but discover:
ADMIN
Now the 403 makes much more sense.
The rule worth remembering
If your authorities look like:
ADMIN
USER
MANAGER
use:
hasAuthority(...)
If they look like:
ROLE_ADMIN
ROLE_USER
ROLE_MANAGER
then Spring's default role convention works naturally with:
hasRole(...)
That's basically it.
A tiny naming difference can be the difference between:
200 OK
and an afternoon spent staring at:
403 Forbidden
If you want to inspect the same concepts inside a working Spring Boot project, AuthKit Lite is free and open source. It includes JWT authentication, refresh tokens, role-based authorization, and optional WebAuthn passkeys.
For applications that need a broader authentication foundation — account lifecycle, independent sessions, OAuth2/OIDC, passkeys, magic links, and refresh-session replay handling — there's also AuthKit Pro.
Top comments (0)