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..
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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.