Senior 9 min · May 23, 2026

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.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is Role-Based Access Control in Spring Boot?

Role-Based Access Control (RBAC) in Spring Boot is a security pattern where users are assigned roles (ADMIN, USER, MANAGER) and those roles grant permissions to access specific methods or endpoints. Spring Security implements RBAC through a combination of SecurityContextHolder (which holds the authenticated principal and their GrantedAuthority list) and AOP proxies that intercept method calls to evaluate authorization expressions before execution proceeds.

Think of RBAC like a hotel key card system.

Method security in Spring Boot operates via the @EnableMethodSecurity annotation on a @Configuration class, which registers AspectJ-based interceptors around any bean method annotated with @PreAuthorize, @PostAuthorize, @PreFilter, or @PostFilter. @PreAuthorize evaluates a SpEL expression before the method executes, returning a 403 AccessDeniedException if the expression is false. @PostAuthorize evaluates after execution, enabling data-driven checks like verifying the returned object belongs to the calling user.

Under the hood, granted authorities are strings stored in a Collection<GrantedAuthority> attached to the Authentication object. Spring Security's SimpleGrantedAuthority wraps plain strings. The hasRole() expression processor automatically prepends 'ROLE_' before comparison, which is why authorities are typically stored as 'ROLE_ADMIN' when using the role-based vocabulary. hasAuthority() skips this prefix logic entirely, giving you direct control over the string comparison.

Plain-English First

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.

Self-Invocation Bypasses Method Security
Calling a @PreAuthorize-annotated method from within the same class (this.method()) skips the AOP proxy entirely. The annotation is silently ignored. Always call secured methods through an injected proxy reference, never through 'this'.
Production Insight
After migrating 47 microservices from Spring Boot 2 to 3, the most common RBAC breakage was teams that had @EnableGlobalMethodSecurity on a config class that was no longer component-scanned after package restructuring.
Key Takeaway
Use @EnableMethodSecurity (not @EnableGlobalMethodSecurity) in Spring Boot 3.x, and always test 403 paths explicitly — silent no-ops are the deadliest security bugs.

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.

Pick One: hasRole() XOR hasAuthority()
Choose either hasRole() (with 'ROLE_' prefix convention) or hasAuthority() (exact match) and apply it consistently across the entire codebase. Mixing both in the same project is a maintenance and security hazard.
Production Insight
In a fintech project, an entire admin feature was silently accessible to all users for 3 sprints because the team used hasRole('ADMIN') but stored 'ADMIN' (unprefixed) in the JWT claims mapper. No test caught it because tests used @WithMockUser(roles={'ADMIN'}) which correctly adds 'ROLE_ADMIN' automatically.
Key Takeaway
hasRole('X') checks for 'ROLE_X'; hasAuthority('X') checks for 'X'. Pick one convention and enforce it in code review.

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.

Cache hasPermission() Calls
Every hasPermission() call that hits the database adds latency. Use a @RequestScope cache bean to memoize permission checks within a single HTTP request — a list page calling getDocument() 20 times should only query permissions once per unique document.
Production Insight
After adding hasPermission() to 30 service methods in an e-commerce platform, page load time doubled due to N+1 permission queries. A request-scoped Guava cache on the evaluator brought latency back to baseline in 2 hours.
Key Takeaway
PermissionEvaluator centralizes domain-object security logic; always cache its results within the request scope to avoid N+1 permission query disasters.

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.

EAGER vs LAZY Role Loading
Use EAGER fetch for roles only if the average user has fewer than 5 roles and your user table is small. For large-scale systems, use LAZY with a dedicated query (JPQL JOIN FETCH) in loadUserByUsername() to avoid the N+1 problem that EAGER loading introduces in bulk operations.
Production Insight
A SaaS platform serving 100k users loaded roles with EAGER fetch on the User entity. Every background job that touched User records triggered unnecessary role joins, causing 40% query overhead. Switching to LAZY with explicit JOIN FETCH only in loadUserByUsername() dropped database CPU by 15%.
Key Takeaway
Load roles explicitly in your UserDetailsService query with JOIN FETCH, cache the result with a short TTL, and evict the cache immediately when roles change to balance freshness and performance.

@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.

Class-Level @PreAuthorize
Annotating a @RestController class with @PreAuthorize("isAuthenticated()") ensures no endpoint is accidentally left public due to a forgotten method-level annotation. Method-level annotations add to (or override) the class-level constraint — they do not replace the base authentication check.
Production Insight
Teams that annotate every @RestController class with @PreAuthorize("isAuthenticated()") as a baseline have caught numerous cases where a new endpoint was added without a method-level security annotation — the class-level annotation prevented unintended public access while the team added the correct per-endpoint annotation.
Key Takeaway
Use @Secured for simple role lists, @PreAuthorize for SpEL expressions; annotate classes with a baseline @PreAuthorize("isAuthenticated()") as a defense-in-depth measure.

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.

Always Test the 403 Path
Most security regressions are not failures where access is correctly denied — they are failures where access is incorrectly granted. Your test suite must explicitly assert that wrong-role requests get 403, not just that correct-role requests get 200.
Production Insight
In a B2B SaaS platform, a code review requirement was added: any PR touching a @RestController must include at least one test with a wrong-role user expecting 403. This single policy caught 11 authorization regressions in 6 months that previously would have reached production.
Key Takeaway
Write parameterized security tests that cover every role × endpoint combination, always asserting both granted (200) and denied (403/401) paths — the denials are the more important tests.
● Production incidentPOST-MORTEMseverity: high

The Missing @EnableMethodSecurity That Exposed All Admin Endpoints

Symptom
After upgrading from Spring Boot 2.7 to 3.1, the /api/admin/** endpoints started returning 200 OK for regular USER-role accounts. Automated smoke tests (which only checked HTTP status, not data) passed. The issue was discovered by a QA engineer manually testing the admin panel.
Assumption
The team assumed @EnableGlobalMethodSecurity(prePostEnabled = true) on the legacy config class would continue to work after the Spring Boot 3.x upgrade since it was still present in the codebase.
Root cause
@EnableGlobalMethodSecurity was deprecated in Spring Security 6 and its behavior changed subtly. The security config class had been refactored to not extend WebSecurityConfigurerAdapter (which was also removed), and during that refactor the method security enablement was placed on a class that was no longer picked up by component scanning due to a package restructure. The AOP interceptors were never registered, so @PreAuthorize annotations became no-ops — the methods executed without any authorization check.
Fix
Added @EnableMethodSecurity (the new Spring Security 6 annotation) to the primary @Configuration class that was confirmed to be in the component scan path. Added an integration test that specifically calls an @PreAuthorize-protected method as an unauthenticated user and asserts a 401, and as a USER-role user and asserts a 403. These tests would catch the silent no-op regression in future upgrades.
Key lesson
  • 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.
Production debug guideSymptom → root cause → fix5 entries
Symptom · 01
User with correct role gets 403 Forbidden on a @PreAuthorize-annotated method
Fix
Enable Spring Security DEBUG logging (logging.level.org.springframework.security=DEBUG) and inspect the authentication object printed during the access decision. The most common cause is a 'ROLE_' prefix mismatch — the user has GrantedAuthority 'ADMIN' but the annotation uses hasRole('ADMIN') which checks for 'ROLE_ADMIN'. Change to hasAuthority('ADMIN') or ensure your UserDetailsService stores authorities as 'ROLE_ADMIN'. Also verify the bean being called is a Spring-managed proxy (not newed up manually), since @PreAuthorize only fires on proxied beans.
Symptom · 02
@PreAuthorize annotations are completely ignored — every user can call any method
Fix
Method security is not enabled. Check that @EnableMethodSecurity is present on a @Configuration class that is actually loaded by the application context. Run the app with --debug flag and search for 'MethodSecurityInterceptor' or 'AuthorizationManagerBeforeMethodInterceptor' in the condition evaluation report. If absent, the config class is not being scanned. Move @EnableMethodSecurity to your main @SpringBootApplication class as a temporary diagnostic step.
Symptom · 03
Method security works in unit tests but not in integration tests or production
Fix
Integration tests may use @SpringBootTest without loading the security auto-configuration, or may be calling methods on the concrete class rather than the Spring proxy. Ensure you inject beans via @Autowired (not constructor-new), use @WithMockUser in tests, and confirm the integration test context loads the security configuration. In production, check for any @Scope('prototype') beans — method security proxies can behave unexpectedly with prototype-scoped beans called from singleton beans.
Symptom · 04
Custom PermissionEvaluator is not being invoked — hasPermission() always returns true or throws
Fix
Spring Security only uses a custom PermissionEvaluator if you register it via a MethodSecurityExpressionHandler bean. Create a bean of type DefaultMethodSecurityExpressionHandler, call setPermissionEvaluator(myEvaluator), and expose it as a @Bean named 'methodSecurityExpressionHandler'. Without this explicit wiring, Spring uses the DenyAllPermissionEvaluator (which denies everything) or the default no-op evaluator depending on the version.
Symptom · 05
Database-driven roles not reflecting after role change without user logout/re-login
Fix
Spring Security caches the Authentication object in the SecurityContext for the duration of the session. If you update a user's roles in the database, those changes do not propagate until the user authenticates again. Solutions: store minimal role data in the JWT (requires token re-issue), use a session registry to invalidate specific sessions programmatically (SessionRegistry.expireNow()), or implement a short-lived role cache with a TTL that forces periodic re-fetch from the database.
★ RBAC Debug Cheat SheetFast triage commands for Spring Security role access issues in production
403 on protected endpoint for seemingly correct user
Immediate action
Dump the user's actual GrantedAuthority list
Commands
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 -50
Fix now
Compare the authority strings in the dump against what hasRole/hasAuthority expects. Fix the prefix mismatch in UserDetailsService or switch the annotation expression.
Method security annotations appear to do nothing+
Immediate action
Confirm MethodSecurityInterceptor is registered in the context
Commands
curl http://localhost:8080/actuator/beans | jq '.contexts[].beans | keys[] | select(contains("MethodSecurity"))'
grep -i 'EnableMethodSecurity\|GlobalMethodSecurity' src/main/java/**/*.java
Fix now
Add @EnableMethodSecurity to a @Configuration class confirmed to be in the component scan base package.
hasPermission() in @PreAuthorize not calling custom evaluator+
Immediate action
Check if custom PermissionEvaluator bean is registered
Commands
curl http://localhost:8080/actuator/beans | jq '.. | .aliases? // empty' | grep -i permission
grep -rn 'PermissionEvaluator\|methodSecurityExpressionHandler' src/main/java/
Fix now
Expose a DefaultMethodSecurityExpressionHandler @Bean with your evaluator set, named 'methodSecurityExpressionHandler'.
Spring Security Authorization Annotations Compared
AnnotationSpEL SupportParameter AccessEnable FlagBest For
@PreAuthorizeYes (full)Yes (#param)Default (Spring Boot 3)Complex, param-aware authorization
@PostAuthorizeYes (full)Yes (returnObject)Default (Spring Boot 3)Data-driven post-execution checks
@SecuredNoNosecuredEnabled=trueSimple role list checks
@RolesAllowed (JSR-250)NoNojsr250Enabled=truePortable role checks across frameworks
@PreFilterYesYes (Collection)DefaultFiltering input collections by role
@PostFilterYesYes (returnObject)DefaultFiltering output collections by ownership

Key takeaways

1
Use @EnableMethodSecurity (not @EnableGlobalMethodSecurity) in Spring Boot 3.x
the old annotation is deprecated and its replacement has important behavioral differences
2
hasRole('X') checks for 'ROLE_X'; hasAuthority('X') does exact match
pick one convention and enforce it across the entire codebase
3
Method security only works on Spring proxy calls; self-invocation (this.method()) bypasses the AOP interceptor entirely and silently ignores the annotation
4
Implement PermissionEvaluator for domain-object permissions; always cache its results within the request scope to avoid N+1 database queries
5
Always test the 403 and 401 denial paths explicitly
security regressions are usually about incorrect access being granted, not about correct access being denied

Common mistakes to avoid

6 patterns
×

Using hasRole('X') when the authority is stored as 'X' (not 'ROLE_X')

Symptom
Users with correct roles always get 403; switching to a different user with the same role also gets 403
Fix
Either prefix authority strings with 'ROLE_' in UserDetailsService (recommended) or switch the annotation to hasAuthority('X'). Be consistent across the entire codebase.
×

@PreAuthorize on a method called via this.method() (self-invocation)

Symptom
Method security works when called externally but annotations are silently bypassed when called from within the same class
Fix
Inject the bean into itself with @Lazy @Autowired or refactor the logic into a separate Spring-managed class. Never call annotated methods on 'this' directly.
×

Forgetting @EnableMethodSecurity after upgrading from Spring Boot 2 to 3

Symptom
All @PreAuthorize/@Secured annotations stop working; every authenticated user can call every method
Fix
Add @EnableMethodSecurity to a @Configuration class that is in the component scan path. Add integration tests that assert 403 for wrong-role access to catch this regression.
×

Not securing actuator endpoints separately from API endpoints

Symptom
/actuator/env or /actuator/beans exposed to all authenticated users, leaking configuration and bean details
Fix
Add explicit security rules for /actuator/** in SecurityFilterChain: requireRole ADMIN for sensitive actuator endpoints, permitAll only for /actuator/health and /actuator/info.
×

Using EAGER fetch for user roles in large applications

Symptom
Any query touching User entities in batch operations loads role joins unnecessarily, causing high database CPU
Fix
Use LAZY fetch on the roles relationship and add a specific JPQL query with JOIN FETCH in UserDetailsService to load roles only when building the UserDetails object.
×

No test coverage for the 403/401 denial paths

Symptom
RBAC regressions where access is incorrectly granted are not caught until they reach production or a manual security audit
Fix
Require test coverage for denial paths (403 with wrong role, 401 with no auth) for every protected endpoint. Use parameterized tests to create a role × endpoint access matrix.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between hasRole('ADMIN') and hasAuthority('ADMIN'...
Q02JUNIOR
How do you enable method-level security in Spring Boot 3.x?
Q03SENIOR
Why doesn't @PreAuthorize fire when a method calls another @PreAuthorize...
Q04SENIOR
How would you implement hierarchical roles where ADMIN can do everything...
Q05SENIOR
How do you implement per-object permissions like 'users can only edit th...
Q06SENIOR
What is the correct order of security interceptors when @Transactional a...
Q07SENIOR
How do you handle role changes that need to take effect immediately for ...
Q08SENIOR
Describe how you would test that an @PreAuthorize annotation is actually...
Q01 of 08JUNIOR

What is the difference between hasRole('ADMIN') and hasAuthority('ADMIN') in Spring Security?

ANSWER
hasRole('ADMIN') automatically prepends 'ROLE_' and checks for the authority 'ROLE_ADMIN'. hasAuthority('ADMIN') performs an exact string match on 'ADMIN'. They differ only in the prefix convention. Mixing them in the same codebase causes 403 bugs that are hard to debug.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I use @PreAuthorize on a @Repository or @Component, not just @Service/@Controller?
02
How do I get the currently authenticated username inside a service method?
03
Is @PreAuthorize evaluated before or after Spring validation (@Valid)?
04
How do I secure WebSocket endpoints with Spring Security?
05
Can @PreAuthorize access request headers or body content in its SpEL expression?
🔥

That's Spring Security. Mark it forged?

9 min read · try the examples if you haven't

Previous
Refresh Token with Spring Boot JWT
4 / 4 · Spring Security
Next
Service Discovery with Spring Cloud Eureka