Spring Boot

Role-Based Access

Spring Security models access control as GrantedAuthority strings: a role is just an authority with a ROLE_ prefix. You enforce rules at two layers: URL rules in the SecurityFilterChain, and method rules with @PreAuthorize and friends. This entry covers both layers, ownership checks, role hierarchies, loading permissions from a database, and how to test it all. It also covers the mistakes that cause real vulnerabilities or confusing 403s: prefix mismatches, @PostAuthorize running after side effects, and stale permissions in long-lived tokens.

URL Authorization in SecurityFilterChain

URL rules are your first line of defence and your safety net: end the chain with anyRequest().authenticated() (or denyAll()) so any endpoint you forget is protected by default. Rules are checked in order and the first match wins, so put specific patterns before broad ones. Know the difference between the two checks. hasRole("ADMIN") looks for the authority ROLE_ADMIN and adds the prefix for you; passing "ROLE_ADMIN" to it throws an error. hasAuthority("BILLING_READ") compares the exact string. A common convention is roles for broad job functions and authorities for specific permissions. Two more details. Since Spring Security 6, a pattern like /api/v1/products no longer matches /api/v1/products/ (trailing slash), so test both if clients vary. And csrf().disable() is only appropriate for stateless APIs authenticated by a token header; if you use session cookies, leave CSRF protection on. Finally, decide what unauthenticated and unauthorised requests return. With no login page configured, an anonymous request commonly gets 403 rather than 401, which confuses API clients. Set the entry point explicitly.
Java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)              // stateless token API only
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))   // 401: who are you?
                .accessDeniedHandler((req, res, e) -> res.sendError(HttpServletResponse.SC_FORBIDDEN)))  // 403: not allowed
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()

                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/v1/reports/**").hasAnyRole("ADMIN", "MANAGER")

                // Specific method + path rules go before any broader rule for the same path.
                .requestMatchers(HttpMethod.DELETE, "/api/v1/products/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.POST,   "/api/v1/products/**").hasAnyRole("ADMIN", "MANAGER")
                .requestMatchers(HttpMethod.GET,    "/api/v1/products/**").authenticated()

                .requestMatchers("/api/v1/billing/**").hasAuthority("BILLING_READ")

                .anyRequest().denyAll())                         // forgotten endpoints are closed by default
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

Method Security with @PreAuthorize

URL rules cannot see method arguments or loaded data, which is why you also use method security. Turn it on with @EnableMethodSecurity; @PreAuthorize and @PostAuthorize are enabled by default, while @Secured and JSR-250 are opt-in. Method security works through a proxy, so it only fires when the call comes from outside the bean. A self-invocation (this.delete(id)) skips the check entirely, as do private methods. Put the annotation on service methods that are called from controllers or other beans. Two points about expressions. Referring to a parameter by name (#ownerId) needs the compiler flag -parameters, which Spring Boot's Maven and Gradle plugins set for you; otherwise add @P("ownerId") on the parameter. And an expression like authentication.principal.id only works if your principal actually has an id property, which means you need a custom principal (shown in the database section), not Spring's default User. For ownership rules, avoid long SpEL strings and delegate to a bean. It is easier to read, test, and reuse. Be careful with @PostAuthorize. It runs after the method has executed, so any side effect (a write, an email, an audit event) has already happened when access is denied. Use it only for read-only lookups where the decision depends on the returned object.
Java
@Configuration
@EnableMethodSecurity            // prePost enabled by default; add securedEnabled / jsr250Enabled only if you need them
public class MethodSecurityConfig {}

@Component("productSecurity")
@RequiredArgsConstructor
public class ProductSecurity {
    private final ProductRepository repo;

    public boolean isOwner(Long productId, AppUser user) {
        return repo.existsByIdAndOwnerId(productId, user.getId());
    }
}

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepo;

    @PreAuthorize("hasRole('ADMIN')")
    public void delete(Long id) { productRepo.deleteById(id); }

    @PreAuthorize("hasAuthority('PRODUCT_WRITE')")
    public ProductResponse update(Long id, UpdateProductRequest req) { ... }

    // Ownership via a bean: readable, unit-testable, reusable.
    @PreAuthorize("hasRole('ADMIN') or @productSecurity.isOwner(#id, authentication.principal)")
    public ProductResponse updateOwn(Long id, UpdateProductRequest req) { ... }

    // Rule on a request field
    @PreAuthorize("hasRole('ADMIN') or (hasRole('MANAGER') and #request.price <= 1000)")
    public ProductResponse create(CreateProductRequest request) { ... }

    // @PostAuthorize: decision uses the result. Read-only methods only.
    @PostAuthorize("hasRole('ADMIN') or returnObject.ownerId == authentication.principal.id")
    public ProductResponse findById(Long id) { ... }
}

// Better than @PostAuthorize for lists: filter in the query so unauthorised rows
// are never loaded. @PostFilter loads everything first, then discards.
//   productRepo.findByOwnerId(currentUser.getId())

@Secured and @RolesAllowed

Both annotations check role names only, with no expressions and no access to parameters. @Secured is Spring's; @RolesAllowed is the standard JSR-250 one. Both must be switched on in @EnableMethodSecurity. They are fine for coarse "only admins" rules, but @PreAuthorize does everything they do and more, so most new code uses only that. The part that trips people up is the prefix. @Secured takes the full authority name ("ROLE_ADMIN"). @RolesAllowed takes the role name without the prefix ("ADMIN"), because the prefix is added for you. Mixing the two conventions produces access denied errors that look like data problems. Also, @AuthenticationPrincipal only works on controller method parameters, where Spring MVC resolves it. In a service method it does nothing. In a service, read the current user from SecurityContextHolder or pass the user in explicitly.
Java
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
public class MethodSecurityConfig {}

@Service
public class OrderService {

    @Secured("ROLE_ADMIN")                          // full authority name
    public void cancelAny(Long orderId) { ... }

    @Secured({"ROLE_ADMIN", "ROLE_MANAGER"})        // OR
    public Page<OrderResponse> findAll(Pageable pageable) { ... }

    @RolesAllowed("USER")                           // JSR-250: no ROLE_ prefix
    public OrderResponse findMine(Long orderId, Long userId) { ... }
}

// @AuthenticationPrincipal belongs on the controller; pass what the service needs down.
@GetMapping("/orders/{id}")
OrderResponse get(@PathVariable Long id, @AuthenticationPrincipal AppUser user) {
    return orderService.findMine(id, user.getId());
}

Role Hierarchy

A hierarchy says that higher roles inherit lower ones, so you do not have to write hasAnyRole("ADMIN", "MANAGER", "EDITOR", "USER") everywhere. Define it once as a RoleHierarchy bean. The builder API shown here (RoleHierarchyImpl.withDefaultRolePrefix()) is from Spring Security 6.3 and later, which is Spring Boot 3.3+. On older versions, build it from a string instead: RoleHierarchyImpl.fromHierarchy("ROLE_ADMIN > ROLE_MANAGER > ROLE_EDITOR > ROLE_USER") on 6.3, or setHierarchy(...) before that. How the bean gets applied depends on the version. Recent releases pick up a RoleHierarchy bean automatically for method security. On older ones you must wire it into a MethodSecurityExpressionHandler, and that bean has to be static so it is created before the rest of the configuration. Because behaviour has shifted between versions, do not assume: write a test (see the testing section) that checks an ADMIN can reach a MANAGER-only endpoint and method. If it fails, the hierarchy is not wired for that layer. One gotcha: a hierarchy only affects role checks. It does nothing for hasAuthority("PRODUCT_WRITE") unless you also include that authority in the hierarchy.
Java
@Configuration
public class RoleHierarchyConfig {

    @Bean
    RoleHierarchy roleHierarchy() {
        return RoleHierarchyImpl.withDefaultRolePrefix()      // Spring Security 6.3+
            .role("ADMIN").implies("MANAGER")
            .role("MANAGER").implies("EDITOR")
            .role("EDITOR").implies("USER")
            .build();
    }

    // Needed on versions that do not pick the bean up automatically. Note: static.
    @Bean
    static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
        DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
        handler.setRoleHierarchy(roleHierarchy);
        return handler;
    }
}

@Service
public class ContentService {

    @PreAuthorize("hasRole('EDITOR')")    // EDITOR, MANAGER and ADMIN all pass
    public ContentResponse publish(Long contentId) { ... }

    @PreAuthorize("hasRole('MANAGER')")   // MANAGER and ADMIN only
    public void approve(Long contentId) { ... }
}

Permissions Stored in the Database

Annotations are compiled into your code, so you cannot change what an endpoint requires at runtime. What you can make dynamic is which roles hold which permissions. Code checks stable permission names (ORDER_CANCEL), and the database decides who has them. Build a custom principal so you can carry the user id, which also makes expressions like authentication.principal.id work. Extending Spring's User class is the least code. Two real-world problems with this design. First, load roles and permissions with an explicit join fetch rather than EAGER mappings. EAGER on a many-to-many fires extra queries on every user load and is hard to undo later. Second, authorities are copied into the authentication when the user logs in, or into the JWT when it is issued. If an admin removes a permission, existing sessions and tokens keep the old authorities until they expire. Keep access tokens short-lived (minutes), or look permissions up per request if you need instant revocation.
Java
@Entity
@Table(name = "roles")
public class Role {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false, unique = true, length = 50)
    private String name;                                   // ROLE_ADMIN
    @ManyToMany                                            // LAZY by default; fetch explicitly
    @JoinTable(name = "role_permissions",
        joinColumns = @JoinColumn(name = "role_id"),
        inverseJoinColumns = @JoinColumn(name = "permission_id"))
    private Set<Permission> permissions = new HashSet<>(); // PRODUCT_WRITE, ORDER_CANCEL ...
}

public interface UserRepository extends JpaRepository<UserEntity, Long> {
    @Query("""
        select distinct u from UserEntity u
        left join fetch u.roles r
        left join fetch r.permissions
        where u.email = :email
        """)
    Optional<UserEntity> findByEmailWithRoles(@Param("email") String email);
}

// Custom principal: carries the id so SpEL can use authentication.principal.id
public class AppUser extends User {
    private final Long id;
    public AppUser(Long id, String username, String password, boolean enabled,
                   Collection<? extends GrantedAuthority> authorities) {
        super(username, password, enabled, true, true, true, authorities);
        this.id = id;
    }
    public Long getId() { return id; }
}

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepo;

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String email) {
        UserEntity user = userRepo.findByEmailWithRoles(email)
            .orElseThrow(() -> new UsernameNotFoundException("Invalid credentials"));

        Set<GrantedAuthority> authorities = new HashSet<>();
        for (Role role : user.getRoles()) {
            authorities.add(new SimpleGrantedAuthority(role.getName()));            // ROLE_ADMIN
            role.getPermissions().forEach(p ->
                authorities.add(new SimpleGrantedAuthority(p.getName())));          // ORDER_CANCEL
        }
        return new AppUser(user.getId(), user.getEmail(), user.getPassword(), user.isEnabled(), authorities);
    }
}

@Service
public class OrderService {
    @PreAuthorize("hasAuthority('ORDER_READ')")   public Page<OrderResponse> findAll(Pageable p) { ... }
    @PreAuthorize("hasAuthority('ORDER_CANCEL')") public void cancel(Long orderId) { ... }
    @PreAuthorize("hasAuthority('ORDER_REFUND')") public RefundResponse refund(Long orderId) { ... }
}

Testing Authorization

Access rules are exactly the kind of code that breaks silently: a refactor widens a matcher and nobody notices. Test both layers. Use @WithMockUser(roles = "ADMIN") to simulate a user (it creates the authority ROLE_ADMIN), and assert on the status code for URLs and on AccessDeniedException for methods. Check the negative cases, not just the happy path: an anonymous user gets 401, a logged-in user without the right role gets 403, and a user cannot touch someone else's resource.
Java
@SpringBootTest
@AutoConfigureMockMvc
class AuthorizationTest {

    @Autowired MockMvc mvc;
    @Autowired ProductService productService;

    @Test
    void anonymousGets401() throws Exception {
        mvc.perform(get("/api/v1/admin/stats")).andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(roles = "USER")
    void userGets403OnAdminEndpoint() throws Exception {
        mvc.perform(get("/api/v1/admin/stats")).andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void adminInheritsManagerAccess() throws Exception {        // proves the hierarchy is wired
        mvc.perform(get("/api/v1/reports/sales")).andExpect(status().isOk());
    }

    @Test
    @WithMockUser(roles = "USER")
    void methodSecurityBlocksDelete() {
        assertThatThrownBy(() -> productService.delete(1L))
            .isInstanceOf(AccessDeniedException.class);
    }
}

// Common mistakes checklist
//  hasRole("ROLE_ADMIN")          -> error: the prefix is added for you
//  @RolesAllowed("ROLE_ADMIN")    -> wrong convention: use "ADMIN"
//  this.securedMethod()           -> no proxy, no check
//  @PostAuthorize on a write      -> the write has already happened
//  principal.id with default User -> SpEL error: needs a custom principal
//  long-lived JWT with authorities-> removed permissions keep working until expiry