Spring Security: Mastering Roles and Privileges in 2026





Hardcoding Roles? Stop. Use Privileges Instead (Spring Security 6) | www.codegigs.app






Hardcoding Roles? Stop. Use Privileges Instead (Spring Security 6).
Spring Security: Mastering Roles and Privileges in 2026

You start a project. You need an Admin. You write @PreAuthorize("hasRole('ADMIN')"). It works. You feel good.

Six months later, your Product Manager walks in. “Hey, we need a ‘Support Manager’ role. They need to delete users, just like Admins, but they can’t touch the billing settings.”

Panic.

You now have to grep your entire codebase for hasRole('ADMIN') and change half of them to hasAnyRole('ADMIN', 'SUPPORT_MANAGER'). Your code becomes a mess of OR statements. I’ve been there. I’ve refactored that mess. It’s miserable.

At www.codegigs.app, we teach over 50,000 developers to avoid this trap by decoupling Who you are (Role) from What you can do (Privilege). Here is how you build a permission system that doesn’t crumble when requirements change.

The Quick Fix: Granular Authorities

Stop checking for Roles. Start checking for Privileges (Authorities).

Instead of this:

@PreAuthorize("hasRole('ADMIN')") // Brittle

Do this:

@PreAuthorize("hasAuthority('USER_DELETE')") // Scalable

Now, it doesn’t matter if the user is an Admin, a Support Manager, or a Super-God-User. If they have the USER_DELETE authority, they get in. The code never changes.

The Architecture: User → Role → Privilege

Most tutorials show a simple User table with a role column. That’s fine for a blog. For a real app, you need a Many-to-Many relationship chain.

  1. User: Has many Roles.
  2. Role: Has many Privileges.
  3. Privilege: The atomic unit of permission (e.g., OP_READ, OP_WRITE).

When a user logs in, you fetch their roles, collect all the privileges attached to those roles, and load those privileges into the Spring Security context.

Step 1: The Database Entities

You need to map this out in JPA. It’s a bit of boilerplate, but you only write it once.

// Role.java
// Spring Boot 3.2.1, JPA
package app.codegigs.model;

import jakarta.persistence.*;
import java.util.Collection;

@Entity
public class Role {
    
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
        name = "roles_privileges", 
        joinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"), 
        inverseJoinColumns = @JoinColumn(name = "privilege_id", referencedColumnName = "id"))
    private Collection<Privilege> privileges;

    // Getters and Setters...
}

Note on FetchType.EAGER: I usually tell people to avoid EAGER loading like the plague. But for Roles and Privileges? It’s actually okay. You almost always need the privileges when you load the role during login. Just don’t make the User’s role list EAGER if you can avoid it.

This exact setup was debated on a Stack Overflow thread with 400+ votes. The consensus? EAGER loading permissions is the performance trade-off you pay for a fast login process.

Step 2: The UserDetailsService (The Bridge)

This is where the magic happens. We need to translate our database objects into something Spring Security understands (`GrantedAuthority`).

This is the production pattern we use at www.codegigs.app to flatten the hierarchy.

// MyUserDetailsService.java
package app.codegigs.security;

import app.codegigs.model.Privilege;
import app.codegigs.model.Role;
import app.codegigs.model.User;
import app.codegigs.repository.UserRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

@Service("userDetailsService")
@Transactional
public class MyUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public MyUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email);
        if (user == null) {
            throw new UsernameNotFoundException("No user found with username: " + email);
        }
        
        // Return the Spring User with the flattened authorities list
        return new org.springframework.security.core.userdetails.User(
            user.getEmail(), 
            user.getPassword(), 
            user.isEnabled(), 
            true, true, true, 
            getAuthorities(user.getRoles())
        );
    }

    // Helper method to flatten Roles -> Privileges -> GrantedAuthority
    private Collection<? extends GrantedAuthority> getAuthorities(Collection<Role> roles) {
        List<String> privileges = new ArrayList<>();
        List<GrantedAuthority> authorities = new ArrayList<>();
        
        for (Role role : roles) {
            // Add the role itself as an authority (optional, but useful)
            privileges.add(role.getName());
            
            // Add every privilege inside that role
            for (Privilege item : role.getPrivileges()) {
                privileges.add(item.getName());
            }
        }
        
        for (String privilege : privileges) {
            authorities.add(new SimpleGrantedAuthority(privilege));
        }
        return authorities;
    }
}

Look at lines 53-58. We are iterating through every role assigned to the user, grabbing every privilege associated with those roles, and dumping them into one big bucket of strings.

Why do we do this? Because Spring Security’s SecurityContext doesn’t care about your hierarchy. It just wants a list of strings to check against. By flattening it here, checking permissions later becomes O(1) fast.

Step 3: Updating the Security Config

Now that our user has granular authorities (like USER_DELETE), we need to tell Spring to check for them.

If you’re still using WebSecurityConfigurerAdapter, stop. It’s removed in Spring Security 6. Here is the modern SecurityFilterChain approach.

// SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            // Use hasAuthority, NOT hasRole
            .requestMatchers("/admin/**").hasAuthority("ADMIN_ACCESS")
            .requestMatchers(HttpMethod.DELETE, "/users/**").hasAuthority("USER_DELETE")
            .anyRequest().authenticated()
        );
    return http.build();
}

Crucial Mistake: Do not use hasRole('USER_DELETE'). Remember, hasRole automatically adds a ROLE_ prefix. Your privilege in the database is likely just “USER_DELETE”, so hasRole will look for “ROLE_USER_DELETE” and fail. I wasted an entire afternoon on this exact typo once.

If you are confused about the prefix logic, check out our deep dive on Roles vs Authorities.

What About Method Security?

Securing URLs is fine, but real security happens at the service layer. You want to prevent the operation regardless of where the request came from.

First, enable it:

@Configuration
@EnableMethodSecurity // Replaces @EnableGlobalMethodSecurity
public class MethodSecurityConfig { }

Then, lock down your services:

@Service
public class UserManagementService {

    @PreAuthorize("hasAuthority('USER_DELETE')")
    public void deleteUser(Long id) {
        userRepo.deleteById(id);
    }
}

This is cleaner. It’s safer. And if you want to know how to handle the exceptions when a user doesn’t have permission, read our guide on Global Exception Handling.

Common Pitfalls

1. The “ROLE_” Prefix Confusion

I mentioned it before, but I’ll mention it again because it happens constantly. This Reddit thread is full of people raging about 403 errors. Stick to hasAuthority() for everything and your life gets simpler.

2. Database Performance

If a user has 20 roles and each role has 50 privileges, you might be loading a lot of data. Ensure your join tables are indexed. In extreme cases, cache the authority list in Redis during login so you aren’t hitting the DB on every request.

Scale Your Security

Handling hierarchical roles? Dynamic permissions? Join 50,000+ developers mastering advanced authorization patterns.

Get the full Spring Security Masterclass at www.codegigs.app →

Conclusion

Separating Roles and Privileges feels like extra work at the start. It is. You have to create two extra tables and write a loop in your UserDetailsService.

But the first time your boss asks for a custom role with a weird mix of permissions, and you can say “Done” in 30 seconds by running a SQL insert instead of deploying code? That’s when you win.

If you’re ready to get even more granular—like “User can only delete their own account”—you need to move beyond simple strings. Check out our next tutorial on Custom Security Expressions to handle that logic cleanly.


Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top