Authorization: Roles, Authorities (RBAC) & Custom Filters
Authentication answers who you are. This chapter is authorization — roles, authorities, and how Spring Security actually decides what an authenticated user is allowed to do.
Learning objectives
- Beginner: Explain the precise technical difference between a 'role' and an 'authority' in Spring Security.
- Intermediate: Configure URL-level authorization rules using roles and authorities correctly.
- Advanced: Write a custom filter that extends the security chain with application-specific authorization logic.
🌱 BEGINNER STORY
Think of a corporate office building where every employee badge is coded with a ROLE — 'Engineer', 'Manager', 'Security Guard'. Each role bundles together a set of specific door-access permissions — an ENGINEER role might grant access to the engineering floor and the server room, while a MANAGER role additionally grants access to the executive floor. Individual door-access permissions are the AUTHORITIES ('server-room:enter', 'exec-floor:enter'); the ROLE is just a convenient, human-friendly bundle of authorities that gets assigned to a badge. This is exactly Role-Based Access Control (RBAC): authorities are the atomic permissions, roles are named bundles of those permissions.
Figure: A role is just a convenient bundle of individual authorities assigned to a user.
In Spring Security, BOTH roles and authorities are ultimately represented as GrantedAuthority objects (typically SimpleGrantedAuthority, just a String). The only difference is a NAMING CONVENTION: a 'role' is, by convention, a GrantedAuthority whose string value is prefixed with ROLE_ (e.g., ROLE_ADMIN), and Spring Security's role-checking helper methods (hasRole('ADMIN')) automatically add/expect that prefix for you. An 'authority' has no such prefix convention and typically represents a finer-grained permission (e.g., accounts:read, orders:cancel).
| Role | Authority | |
|---|---|---|
| Naming convention | Prefixed with ROLE_ (e.g., ROLE_ADMIN) | No fixed prefix (e.g., accounts:write) |
| Granularity | Coarse-grained (a job function/persona) | Fine-grained (a specific permission) |
| Checked with | hasRole('ADMIN') / hasAnyRole(...) | hasAuthority('accounts:write') / hasAnyAuthority(...) |
| Underlying type | GrantedAuthority (SimpleGrantedAuthority) | GrantedAuthority (SimpleGrantedAuthority) |
A very common, production-proven schema separates users from their granted authorities into their own table, allowing a single user to hold MULTIPLE roles/authorities cleanly:
CREATE TABLE authorities (
customer_id BIGINT NOT NULL, name VARCHAR(50) NOT NULL, -- e.g. "ROLE_ADMIN", "accounts:write" CONSTRAINT fk_authorities_customer FOREIGN KEY (customer_id) REFERENCES customer(id) );
@Override
public UserDetails loadUserByUsername(String email) { Customer customer = customerRepository.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException("Not found")); List authorities = customer.getAuthorities().stream() .map(a -> new SimpleGrantedAuthority(a.getName())) .collect(Collectors.toList()); return new org.springframework.security.core.userdetails.User( customer.getEmail(), customer.getPwd(), authorities); }
http.authorizeHttpRequests(requests -> requests
.requestMatchers("/api/public/").permitAll() .requestMatchers("/api/accounts/").hasRole("USER") .requestMatchers(HttpMethod.DELETE, "/api/loans/").hasAuthority("loans:delete") .requestMatchers("/api/admin/").hasAnyRole("ADMIN", "SUPPORT") .anyRequest().authenticated());
Rule ORDER matters: Spring Security evaluates requestMatchers top-to-bottom and applies the FIRST match — a common bug is placing a broad rule (like /api/) BEFORE a more specific one (/api/admin/), which means the specific rule is never reached.
Figure: You can insert a custom filter before, after, or at the position of an existing filter in the chain.
Sometimes built-in filters aren't enough — e.g., you need to log every request's IP + timestamp for audit compliance, or validate an internal service-to-service API key BEFORE Spring Security's standard authentication runs. You write a plain javax/jakarta Filter (or extend OncePerRequestFilter, which guarantees single execution per request even across internal forwards) and register it at a specific position:
public class RequestValidationBeforeFilter extends OncePerRequestFilter {
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String apiKey = request.getHeader("X-API-KEY"); if (apiKey == null || !apiKey.equals("expected-internal-key")) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing/invalid API key"); return; // stop the chain here } filterChain.doFilter(request, response); // continue to next filter } } // Registering it BEFORE the standard username/password filter: http.addFilterBefore(new RequestValidationBeforeFilter(), UsernamePasswordAuthenticationFilter.class); // Registering it AFTER a given filter: http.addFilterAfter(new AuditLoggingFilter(), BasicAuthenticationFilter.class); // Registering it AT the same position (replacing/running alongside): http.addFilterAt(new CustomJwtAuthFilter(), UsernamePasswordAuthenticationFilter.class);
| Method | When To Use |
|---|---|
| addFilterBefore() | Run your logic BEFORE a specific existing filter — e.g., validate an API key before Spring Security even attempts standard authentication. |
| addFilterAfter() | Run your logic AFTER a specific existing filter — e.g., audit-log a request only once basic authentication has already resolved. |
| addFilterAt() | Insert your filter at the SAME position as an existing one — most commonly used to insert a custom JWT validation filter at the UsernamePasswordAuthenticationFilter position for stateless token-based auth. |
🌱 BEGINNER STORY
Continuing the badge-access analogy: the front-door logbook (Authentication Events, Chapter 5) records who got INTO the building. But a good security office ALSO keeps a separate logbook of denied-access attempts at INTERNAL doors — 'Rahul badged into the building fine, but was denied at the Server Room door at 3:41 PM.' That's a much stronger signal of a compromised badge or an employee overstepping their role than a simple failed front-door entry. Spring Security publishes exactly this as an AuthorizationDeniedEvent whenever a method-security or request-level authorization check fails for an ALREADY-authenticated user.
@Component
public class AuthorizationAuditListener { private static final Logger log = LoggerFactory.getLogger(AuthorizationAuditListener.class); @EventListener public void onAuthorizationDenied(AuthorizationDeniedEvent<?> event) { Authentication authentication = event.getAuthentication().get(); log.warn("ACCESS DENIED: user='{}' attempted='{}' decision='{}'", authentication.getName(), event.getObject(), event.getAuthorizationResult()); // e.g., increment a per-user "suspicious activity" counter, // or trigger an alert if the same user is denied repeatedly in a short window. } } // Publishing authorization events must be explicitly enabled (opt-in, since it // adds a small overhead to every authorization check): @Bean public AuthorizationEventPublisher authorizationEventPublisher(ApplicationEventPublisher publisher) { return new SpringAuthorizationEventPublisher(publisher); }
Notice the important distinction from Chapter 5's authentication events: an AuthorizationDeniedEvent means the user was successfully IDENTIFIED (they're logged in) but tried to do something they don't have permission for — this is a much stronger red flag for insider-threat or privilege-escalation monitoring than a simple failed login, since it means a KNOWN, authenticated identity is probing boundaries they shouldn't be near.
⚠ A subtle but important gotcha
Unlike authentication events (published automatically), authorization event publishing is OPT-IN — you must explicitly expose an AuthorizationEventPublisher bean, or your @EventListener for AuthorizationDeniedEvent will simply never fire, with no error or warning telling you why. This is a commonly missed step when teams try to add access-denied auditing.
⚠ Traps in real projects
hasRole('ADMIN') automatically expects the underlying authority to be stored as 'ROLE_ADMIN' — a very common bug is storing the raw role as just 'ADMIN' in the database and then being confused why hasRole('ADMIN') never matches (you'd need hasAuthority('ADMIN') instead, or store it correctly prefixed). Ordering of requestMatchers rules is evaluated FIRST-MATCH-WINS, top to bottom — a catch-all placed too early silently shadows more specific rules declared after it. A custom filter that forgets to call filterChain.doFilter() (when it SHOULD continue) silently blocks every request from ever reaching later filters or the controller — a notoriously hard bug to spot because there's often no visible error, just an infinite loading spinner or a generic timeout. Extending Filter directly (instead of OncePerRequestFilter) risks the same filter running MULTIPLE times per request in certain servlet dispatch scenarios (e.g., internal forwards/includes) — OncePerRequestFilter guards against this automatically.
Want a visual for this concept?
Generate a diagram tailored to “Authorization: Roles, Authorities (RBAC) & Custom Filters” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →