Role-Based Access Control in Spring Boot: @PreAuthorize, @Secured, and Method Security
Master Spring Boot RBAC with @PreAuthorize, @Secured, @EnableMethodSecurity, hasRole vs hasAuthority, custom PermissionEvaluator, and database-driven roles.
- Annotate methods with @PreAuthorize("hasRole('ADMIN')") to enforce role checks at the method level
- Enable method security with @EnableMethodSecurity on your config class (replaces @EnableGlobalMethodSecurity)
- hasRole('ADMIN') auto-prepends 'ROLE_' prefix; hasAuthority('ADMIN') matches the exact string
- Use @Secured for simple role lists; @PreAuthorize for SpEL expressions including parameter inspection
- Implement PermissionEvaluator for domain-object-level permissions beyond coarse-grained roles
Think of RBAC like a hotel key card system. Each guest (user) has a card (role) that opens certain doors (resources). The front desk (Spring Security) checks your card at every door automatically. You program which cards open which doors once, and the hotel enforces it everywhere without you posting a guard at each room.
At 2 AM a frantic Slack message arrives: a junior dev accidentally exposed the /admin/users/delete endpoint to all authenticated users because they forgot to add a security annotation. 4,000 user records were soft-deleted in minutes. This is the real cost of ad-hoc authorization — scattered if-else role checks, forgotten endpoints, and no single source of truth.
Spring Security's method-level RBAC gives you a declarative, annotation-driven authorization model that fails secure by default. Instead of sprinkling role checks throughout business logic, you declare intent at the method boundary and Spring enforces it automatically via AOP proxies. Miss an annotation? Pair it with a deny-by-default HTTP security configuration and the request gets rejected before reaching your code.
The evolution from @EnableGlobalMethodSecurity (deprecated in Spring Security 6) to @EnableMethodSecurity matters more than most tutorials admit. The new annotation enables pre/post annotations by default, adds AuthorizationManager-based checks (replacing the older AccessDecisionManager chain), and integrates cleanly with reactive stacks. Understanding what changed prevents subtle security regressions when migrating.
hasRole vs hasAuthority is a deceptively simple distinction that causes real bugs. hasRole('ADMIN') silently prepends 'ROLE_' and checks for 'ROLE_ADMIN' in the granted authorities. hasAuthority('ADMIN') performs an exact match. Teams that store 'ADMIN' in the database but use hasRole('ADMIN') in annotations spend hours debugging mysterious 403s before discovering the prefix mismatch.
Database-driven roles are where enterprise applications live. Hard-coded roles in annotations are fine for prototypes, but production systems load roles from PostgreSQL or MongoDB, cache them per-request, and sometimes need row-level permission logic. Spring's PermissionEvaluator interface and the @PreAuthorize('#document.owner == authentication.name') pattern handle this elegantly — but only if you understand the underlying proxy mechanics.
This guide covers every layer: global HTTP security, method-level annotations, custom permission evaluation, and the database integration patterns that keep authorization consistent across a microservices fleet.
@EnableMethodSecurity: The Correct Way in Spring Boot 3.x
Spring Security 6 (bundled with Spring Boot 3.x) deprecated @EnableGlobalMethodSecurity and introduced @EnableMethodSecurity. This is not merely a rename — the underlying implementation changed from AccessDecisionManager-based voting to AuthorizationManager-based decisions, which is more composable and supports reactive use cases.
@EnableMethodSecurity enables @PreAuthorize and @PostAuthorize by default (prePostEnabled=true is the default, not false). If you need @Secured or JSR-250 annotations (@RolesAllowed, @PermitAll, @DenyAll), you must opt in explicitly: @EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true).
One critical production gotcha: method security only works on Spring-managed beans accessed through their proxy. If ClassA autowires ClassB and calls myBean.securedMethod(), the proxy intercepts the call. But if ClassB calls this.securedMethod() (a self-invocation), the proxy is bypassed entirely and the annotation is ignored. This is a fundamental AOP proxy limitation, not a Spring Security bug. Fix it by injecting ClassB into itself via @Lazy @Autowired, or by restructuring to avoid self-calls.
The order of interceptors matters when combining @Transactional and @PreAuthorize. By default, @PreAuthorize runs at order Integer.MIN_VALUE (highest priority) and @Transactional runs at order 0. This means authorization is checked before a transaction opens — the correct behavior. If you see authorization logic that queries the database inside a @PreAuthorize expression, be aware it runs in a new transaction context unless you explicitly configure otherwise.
For Spring Boot 3.x teams migrating from 2.x, the migration checklist is: replace @EnableGlobalMethodSecurity with @EnableMethodSecurity, remove extends WebSecurityConfigurerAdapter (replaced by SecurityFilterChain beans), and add explicit securedEnabled/jsr250Enabled flags if those annotation types are used.
this.method()) skips the AOP proxy entirely. The annotation is silently ignored. Always call secured methods through an injected proxy reference, never through 'this'.hasRole vs hasAuthority: The Prefix Problem That Haunts Production
This distinction causes more production 403 bugs than any other RBAC concept. Here is the definitive explanation.
hasRole('ADMIN') is syntactic sugar. Internally it calls hasAuthority('ROLE_ADMIN'). Spring Security assumes all role-based authorities are stored with a 'ROLE_' prefix by convention. When you call hasRole('ADMIN'), it automatically prepends 'ROLE_' and checks if the user has a GrantedAuthority whose string value is exactly 'ROLE_ADMIN'.
hasAuthority('ADMIN') performs an exact string match. No prefix is added. If your UserDetailsService stores the string 'ADMIN' in the granted authorities, then hasAuthority('ADMIN') matches and hasRole('ADMIN') does not (because it would look for 'ROLE_ADMIN').
The production disaster scenario: your database stores roles as 'ADMIN', 'USER', 'MANAGER'. Your UserDetailsService loads them and wraps each in new SimpleGrantedAuthority(roleName) — so the authority string is 'ADMIN'. Your annotations use @PreAuthorize("hasRole('ADMIN')"). This silently fails because Spring Security looks for 'ROLE_ADMIN' but only 'ADMIN' is present. Every admin-role user gets 403.
Fix option 1: Prefix in the database — store 'ROLE_ADMIN', 'ROLE_USER'. Then hasRole() works. Fix option 2: Prefix in UserDetailsService — map roleName to 'ROLE_' + roleName when constructing GrantedAuthority. Fix option 3: Use hasAuthority() everywhere and store unprefixed strings consistently.
Mixing hasRole() and hasAuthority() in the same codebase is a maintenance trap. Pick one convention and apply it everywhere. The Spring Security documentation recommends the 'ROLE_' prefix convention, which makes hasRole() the natural choice — but it requires discipline in your UserDetailsService implementation.
For hierarchical roles (ADMIN implies USER implies VIEWER), use RoleHierarchy: declare the hierarchy in a bean, set it on the security expression handler, and then hasRole('USER') will also pass for ADMIN users without explicit multi-role grants.
Custom PermissionEvaluator for Domain-Object Security
Coarse-grained role checks cover 80% of authorization needs. The remaining 20% — 'a user can only edit their own documents', 'a manager can only approve timesheets for their department', 'a support agent can only view tickets assigned to them' — requires domain-object-level security. This is where PermissionEvaluator comes in.
Spring Security's @PreAuthorize supports hasPermission() expressions that delegate to a registered PermissionEvaluator. There are two overloaded forms: hasPermission(object, permissionName) where object is the domain object itself, and hasPermission(objectId, objectType, permissionName) where Spring loads the object by ID and type. The latter is the production-ready form because it defers the database query to the evaluator, keeping the annotation readable.
A real enterprise scenario: a document management system where users can READ any document in their organization, but WRITE only documents they own, and DELETE only if they're the owner or an ADMIN. The PermissionEvaluator encapsulates this logic in one place rather than scattering it across service methods.
Performance is the critical concern. hasPermission() in @PreAuthorize runs on every method call. If your evaluator makes a database query per invocation, a single page load that calls 20 secured methods generates 20 extra queries. Cache aggressively: use a request-scoped cache (RequestContextHolder or a @RequestScope bean) to deduplicate permission lookups within a single HTTP request. For read-heavy systems, a short-TTL L2 cache (Caffeine or Redis) reduces database pressure significantly.
The hasPermission(id, type, permission) form receives the raw object passed to hasPermission in the SpEL expression. For the three-argument form, the first argument is the target object ID, the second is the target type as a string, and the third is the permission string. Your evaluator must handle the casting from Serializable to Long (or whatever your ID type is).
Test your PermissionEvaluator independently of the full Spring context using unit tests that call evaluate() directly. Integration tests should cover the happy path (permission granted) and the denial path (permission denied throws AccessDeniedException) for every permission type.
Database-Driven Roles: Loading from PostgreSQL at Runtime
Most real applications manage roles in a database, not in application.yml. Users acquire and lose roles over time, and the authorization model needs to reflect those changes without redeployment. Spring Security's UserDetailsService is the integration point — implement it to load roles from your persistence layer.
The schema pattern that scales: a users table, a roles table with a name column (storing values like 'ADMIN', 'USER'), and a user_roles join table. Optionally, a role_permissions table for fine-grained permission strings if you need RBAC with explicit permissions rather than just roles. Keep the roles table small and cache-friendly — roles change rarely, making them ideal for a short-TTL cache.
For JWT-based stateless APIs, the architecture splits into two concerns: authentication time (load roles from DB and embed them in the JWT as claims) and request time (parse roles from the JWT without touching the database). This is fast and scales horizontally, but means role changes only take effect when the current JWT expires (typically 15 minutes for access tokens). For immediate role revocation, you need either a token blacklist (Redis) or a hybrid approach where security-critical roles (like ADMIN revocation) are always verified against the database.
Session-based applications have the opposite characteristic: roles are loaded fresh on each authentication, but the session lifetime (e.g., 30 minutes) delays propagation of role changes. The SessionRegistry approach — maintaining a list of active sessions per user and invalidating them programmatically when roles change — provides the cleanest solution.
Dynamic role loading with Spring Cache (@Cacheable on your UserDetailsService) is a practical middle ground for many applications. Cache the UserDetails object per username with a 5-minute TTL. Role changes propagate within 5 minutes, and database load stays low. Use cache eviction (via an event listener on role-change events) for immediate propagation when needed.
@Secured and @RolesAllowed: When to Use Simpler Annotations
@PreAuthorize is the most powerful method security annotation, but it comes with SpEL overhead and can be over-engineered for simple cases. Spring Security provides two simpler alternatives: @Secured (Spring proprietary) and @RolesAllowed (JSR-250 standard).
@Secured({'ROLE_ADMIN', 'ROLE_MANAGER'}) accepts a list of role strings and grants access if the user has any of them. No SpEL, no expressions — just a simple OR match on the authority list. It is easier to read for simple cases and does not require SpEL parsing on every call. Enable it with securedEnabled = true in @EnableMethodSecurity.
@RolesAllowed is the JSR-250 equivalent. Identical behavior to @Secured but uses the standard javax.annotation (or jakarta.annotation in Spring Boot 3.x) package. Use it when you want annotation portability across frameworks that support JSR-250 (e.g., if the same interface might be used with Jakarta EE or Quarkus in the future). Enable with jsr250Enabled = true.
The trade-off is expressiveness. @PreAuthorize can inspect method parameters (#param), return values (only @PostAuthorize), authentication properties (authentication.name), and call arbitrary bean methods (@myBean.hasAccess(#id)). @Secured and @RolesAllowed cannot do any of this.
A practical guideline used by experienced teams: use @Secured for controller-layer endpoint protection (where role checks are coarse) and @PreAuthorize in the service layer where parameter-level logic is needed. This avoids putting complex SpEL on HTTP entry points (where the URL pattern is usually the primary security boundary anyway) while enabling fine-grained control where business logic lives.
Class-level annotations apply to all methods in the class and are overridden by method-level annotations. @Secured or @PreAuthorize on a @RestController class effectively protects all endpoints, with individual endpoints able to specify stricter or different requirements.
Testing Method Security: @WithMockUser and Security Integration Tests
Security testing is where most teams take shortcuts, and it is exactly where the regressions hide. A complete RBAC test suite validates both the happy path (correct role grants access) and the denial path (incorrect role gets 403, no role gets 401).
Spring Security Test provides @WithMockUser, @WithUserDetails, and @WithSecurityContext. @WithMockUser is the simplest — it creates a mock Authentication with configurable username, password, and roles without touching the database. The roles parameter automatically prepends 'ROLE_' prefix, so @WithMockUser(roles = {"ADMIN"}) grants authority 'ROLE_ADMIN'. This means your tests correctly reflect hasRole() behavior.
@WithUserDetails loads the full UserDetails from your UserDetailsService by username. This is the gold-standard for integration tests because it exercises the same code path as production authentication. It requires the test Spring context to load your UserDetailsService and any repositories it depends on — use @SpringBootTest for this level of integration.
For Slice tests (@WebMvcTest), the Spring context only loads the web layer. UserDetailsService beans in the service layer are not automatically loaded. You must either @MockBean the UserDetailsService, use @WithMockUser, or configure a test-specific security config. @WebMvcTest is appropriate for testing HTTP-layer security (endpoint protection), not for testing business-logic-level @PreAuthorize on service methods.
The parameterized test pattern is particularly powerful for security testing: a single test method with @ParameterizedTest and a @MethodSource that provides (username, role, expectedStatus) tuples. This gives you a matrix of role × endpoint coverage in a compact form and makes it obvious when a new role or endpoint is missing from the test matrix.
The Missing @EnableMethodSecurity That Exposed All Admin Endpoints
- Never rely solely on happy-path smoke tests for security.
- Add explicit 401/403 assertion tests for every protected endpoint.
- After any major framework upgrade, run a dedicated security regression suite that probes unauthorized access paths.
SessionRegistry.expireNow()), or implement a short-lived role cache with a TTL that forces periodic re-fetch from the database.curl -u user:pass http://localhost:8080/actuator/security/user 2>/dev/null | jq '.authorities'grep -i 'granted authority\|access denied\|role' application.log | tail -50Key takeaways
this.method()) bypasses the AOP interceptor entirely and silently ignores the annotationCommon mistakes to avoid
6 patternsUsing hasRole('X') when the authority is stored as 'X' (not 'ROLE_X')
@PreAuthorize on a method called via this.method() (self-invocation)
Forgetting @EnableMethodSecurity after upgrading from Spring Boot 2 to 3
Not securing actuator endpoints separately from API endpoints
Using EAGER fetch for user roles in large applications
No test coverage for the 403/401 denial paths
Interview Questions on This Topic
What is the difference between hasRole('ADMIN') and hasAuthority('ADMIN') in Spring Security?
Frequently Asked Questions
That's Spring Security. Mark it forged?
9 min read · try the examples if you haven't