Authorization & Roles
Once a user is authenticated, authorization decides what they may do. In Spring Security a principal carries a set of GrantedAuthority objects; rules in authorizeHttpRequests (and method annotations) check those authorities against each request. The two flavors — roles and authorities — are the same mechanism with one twist: a “role” is just an authority with a ROLE_ prefix.
Roles vs authorities
| Term | What it is | Example string |
|---|---|---|
| Authority | A raw permission string, used as-is | orders:read, ROLE_ADMIN |
| Role | A coarse-grained authority, conventionally prefixed ROLE_ | ROLE_USER, ROLE_ADMIN |
Roles are best for broad categories of users (USER, ADMIN, MANAGER); fine-grained authorities suit specific permissions (invoice:approve, report:export). They coexist freely on the same principal.
The ROLE_ prefix
This trips up almost everyone. Spring Security’s role helpers automatically add or expect the ROLE_ prefix:
// When you GRANT a role, store it WITH the prefix:
new SimpleGrantedAuthority("ROLE_ADMIN");
User.builder().roles("ADMIN"); // roles() adds ROLE_ → ROLE_ADMIN
// When you CHECK a role, pass it WITHOUT the prefix:
.requestMatchers("/admin/**").hasRole("ADMIN"); // matches ROLE_ADMIN
Authorities, by contrast, are matched verbatim — no prefix is added on either side:
new SimpleGrantedAuthority("orders:read");
.requestMatchers("/orders/**").hasAuthority("orders:read"); // exact match
Warning:
hasRole("ROLE_ADMIN")will never match, because the framework prependsROLE_and looks forROLE_ROLE_ADMIN. PasshasRole("ADMIN").
Request-level rules
The HTTP DSL maps URL patterns to access expressions. Rules are evaluated in order, first match wins.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").hasRole("USER")
.requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
.requestMatchers("/api/reports/**").hasAuthority("report:export")
.requestMatchers("/api/staff/**").hasAnyRole("MANAGER", "ADMIN")
.anyRequest().authenticated());
return http.build();
}
The available access methods:
| Method | Allows when the user has |
|---|---|
hasRole("X") | authority ROLE_X |
hasAnyRole("X","Y") | ROLE_X or ROLE_Y |
hasAuthority("p") | authority p (verbatim) |
hasAnyAuthority("p","q") | p or q |
authenticated() | any authenticated identity |
permitAll() / denyAll() | always / never |
Testing the rules
As a USER hitting an admin-only POST:
curl -u alice:password -X POST http://localhost:8080/api/products \
-H 'Content-Type: application/json' -d '{"name":"Widget"}'
Output:
HTTP/1.1 403 Forbidden
{"status":403,"error":"Forbidden","path":"/api/products"}
A 403 Forbidden means authenticated but not authorized — distinct from 401 Unauthorized, which means not authenticated.
Role hierarchy
Often a higher role should implicitly include lower ones — an ADMIN should pass any USER check without granting both authorities to every admin. Configure a RoleHierarchy bean:
import org.springframework.security.access.hierarchicalroles.*;
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("MANAGER")
.role("MANAGER").implies("USER")
.build();
}
Now an ADMIN automatically satisfies hasRole("MANAGER") and hasRole("USER"). To make method security honor the hierarchy too, expose it through a MethodSecurityExpressionHandler:
@Bean
static MethodSecurityExpressionHandler methodExpressionHandler(RoleHierarchy roleHierarchy) {
DefaultMethodSecurityExpressionHandler handler =
new DefaultMethodSecurityExpressionHandler();
handler.setRoleHierarchy(roleHierarchy);
return handler;
}
Tip: A role hierarchy keeps authority assignment simple — grant a user the single most specific role they need and let the hierarchy imply the rest.
Reading authorities at runtime
@GetMapping("/whoami")
public Map<String, Object> whoami(Authentication auth) {
return Map.of(
"name", auth.getName(),
"authorities", auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList());
}
Output:
{ "name": "bob", "authorities": ["ROLE_ADMIN", "ROLE_USER"] }
Pitfalls
- Mixing prefixed and unprefixed roles — keep grants prefixed (
ROLE_) and checks unprefixed. - Ordering rules wrong — a broad
permitAll()placed early opens paths you meant to lock. - Expecting
hasAuthority("ADMIN")to match a role — it won’t; usehasRole("ADMIN")or grant"ADMIN"as a bare authority.