JWT, OAuth2 & Securing APIs

Roles & Authorities in JWT

18 min Lesson 6 of 13

Roles & Authorities in JWT

A JWT that only proves who you are is half the story. Production APIs also need to know what you are allowed to do. Embedding roles and fine-grained authorities directly inside the token lets every service in your architecture make authorization decisions locally — without a round-trip to a central permission store. This lesson shows you exactly how to do that with Spring Security 6.

Spring Security Concepts: Roles vs. Authorities

Spring Security uses two related but distinct terms:

  • Authority (GrantedAuthority) — any permission string. Examples: READ_REPORTS, DELETE_USERS, SCOPE_openid.
  • Role — a named authority that Spring Security expects to carry the prefix ROLE_. When you call hasRole("ADMIN"), Spring internally checks for the authority ROLE_ADMIN.

In the JWT world, roles and authorities are just claims — key-value pairs baked into the token payload. The naming convention is yours to choose; the prefix rule applies only when you use Spring's hasRole() / hasAuthority() expressions.

Design choice: Many teams store a flat list under a single claim key (e.g. "roles": ["ROLE_ADMIN","ROLE_USER"]) and map them all to Spring GrantedAuthority objects. Others separate coarse roles from fine permissions into two claims. Either works; what matters is that the mapping code and the security expressions agree.

Embedding Roles When Building the JWT

You already generate a token in JwtUtil. Extend generateToken to accept the user's authorities:

// JwtUtil.java import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import org.springframework.security.core.GrantedAuthority; import java.security.Key; import java.util.Collection; import java.util.Date; import java.util.List; import java.util.stream.Collectors; @Component public class JwtUtil { private final Key signingKey = Keys.secretKeyFor(SignatureAlgorithm.HS256); private static final long EXPIRY_MS = 3_600_000; // 1 hour public String generateToken(String username, Collection<? extends GrantedAuthority> authorities) { List<String> roles = authorities.stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); return Jwts.builder() .setSubject(username) .claim("roles", roles) // <-- embed the list .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRY_MS)) .signWith(signingKey) .compact(); } public String extractUsername(String token) { return Jwts.parserBuilder().setSigningKey(signingKey).build() .parseClaimsJws(token).getBody().getSubject(); } @SuppressWarnings("unchecked") public List<String> extractRoles(String token) { return (List<String>) Jwts.parserBuilder().setSigningKey(signingKey).build() .parseClaimsJws(token).getBody().get("roles", List.class); } }
Keep the roles claim small. JWTs travel in every request header. A user with 200 fine-grained permissions would bloat the token significantly. Prefer coarse roles in the JWT and fetch fine permissions from the database only when truly needed.

Restoring Authorities in the Authentication Filter

Your JwtAuthenticationFilter (from Lesson 4) currently builds a UsernamePasswordAuthenticationToken with no authorities. Extend it to extract the roles claim and convert it back to GrantedAuthority objects:

// JwtAuthenticationFilter.java (updated doFilterInternal) import org.springframework.security.core.authority.SimpleGrantedAuthority; import java.util.List; import java.util.stream.Collectors; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); try { String username = jwtUtil.extractUsername(token); List<String> roles = jwtUtil.extractRoles(token); // Convert raw strings to GrantedAuthority List<SimpleGrantedAuthority> authorities = roles.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( username, null, authorities); // <-- third arg = authorities auth.setDetails( new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(auth); } catch (JwtException e) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token"); return; } } chain.doFilter(request, response); }

Once the Authentication object carries the authorities, Spring Security's authorization layer can evaluate expressions like hasRole and hasAuthority correctly for this request — all without touching the database.

Protecting Endpoints by Role

In your SecurityConfig, update the authorization rules:

// SecurityConfig.java @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") // ROLE_ADMIN .requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN") .requestMatchers("/api/reports/**").hasAuthority("READ_REPORTS") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }
hasRole("ADMIN") vs hasAuthority("ROLE_ADMIN"): Both expressions check for the same authority string. hasRole is syntactic sugar that prepends ROLE_ automatically. They are interchangeable — pick one style and stick to it across your codebase.

Method-Level Security with @PreAuthorize

HTTP-path rules are coarse. For fine-grained control, enable method security and annotate individual service methods:

// SecurityConfig.java — add the annotation @Configuration @EnableWebSecurity @EnableMethodSecurity // enables @PreAuthorize, @PostAuthorize, @Secured public class SecurityConfig { ... }
// ReportService.java import org.springframework.security.access.prepost.PreAuthorize; @Service public class ReportService { @PreAuthorize("hasRole('ADMIN') or hasAuthority('READ_REPORTS')") public List<Report> getAllReports() { ... } @PreAuthorize("hasRole('ADMIN')") public void deleteReport(Long id) { ... } // Access the authenticated principal inside the expression @PreAuthorize("authentication.name == #username or hasRole('ADMIN')") public UserProfile getProfile(String username) { ... } }

Method-level annotations compose well with HTTP rules: the HTTP layer rejects completely unauthenticated callers early, and @PreAuthorize provides a second enforcement line for complex business rules.

Security Implications and Distributed-Systems Trade-offs

Embedding roles in the JWT carries an important caveat: the token is self-contained and signed, not live. If you revoke a role from a user in your database, that change does not take effect until their current token expires. Key trade-offs:

  • Pro — scalability: Every service validates the token locally. No permission-store round-trip means lower latency and no single point of failure for authorization.
  • Con — stale roles: A token issued before a role change remains valid until expiry. Mitigations include short expiry times (15–60 minutes) combined with refresh tokens, or a token-revocation list checked on sensitive operations.
  • Pro — auditability: The token snapshot shows what roles the user had at login time, which can be useful for audit logs.
Do not embed highly sensitive permissions in long-lived tokens. Roles like ROLE_SUPER_ADMIN or DELETE_PRODUCTION_DB should live in short-lived tokens (5–15 minutes) or be verified against the database on every call, accepting the performance cost for that extra safety margin.

Updating the Login Endpoint

Pass the user's authorities when generating the token in your auth controller:

// AuthController.java @PostMapping("/api/auth/login") public ResponseEntity<?> login(@RequestBody LoginRequest req) { Authentication auth = authManager.authenticate( new UsernamePasswordAuthenticationToken(req.username(), req.password())); UserDetails user = (UserDetails) auth.getPrincipal(); String token = jwtUtil.generateToken(user.getUsername(), user.getAuthorities()); return ResponseEntity.ok(Map.of("token", token)); }

Summary

Roles and authorities embedded in a JWT give every service in your system a self-contained authorization snapshot: extract the roles claim, convert to GrantedAuthority objects in your filter, and let Spring Security's hasRole / hasAuthority expressions do the rest — either in SecurityConfig for path-level rules or via @PreAuthorize for method-level rules. The trade-off is freshness: understand the window between a role change and token expiry, design your token lifetime accordingly, and reserve per-request database checks only for the most sensitive operations.