Role-Based and Permission-Based Access Control in Spring Security
Introduction Once authentication is solid, the next question is inevitable: what is this user actually allowed to do? Authentication proves who you are; authorization decides what you can access. Conflating the twoβor
Introduction
Once authentication is solid, the next question is inevitable: what is this user actually allowed to do? Authentication proves who you are; authorization decides what you can access. Conflating the twoβor implementing authorization carelesslyβis a common source of privilege-escalation bugs.
In my previous post, Building a Full Enterprise-Ready React + Spring Boot Auth Flow, I promised to expand on role-based access. This post delivers that: we'll cover both role-based access control (RBAC) and the more granular permission-based access control, how they differ, and how to implement each cleanly in Spring Security.
Roles vs. Permissions: What's the Difference?
These terms are often used interchangeably, but they model access at different levels of granularity.
| Concept | Granularity | Example | Best For |
|---|---|---|---|
| Role | Coarse |
ROLE_ADMIN, ROLE_USER
|
Broad user categories |
| Permission (authority) | Fine |
document:read, document:delete
|
Specific actions on resources |
A useful mental model:
- Roles answer "what kind of user is this?"
- Permissions answer "what specific action can this user perform?"
A role is typically a named bundle of permissions. For example, ROLE_EDITOR might group article:read, article:write, and article:publish. This indirection is powerful: you can change what an editor can do without touching every userβyou just adjust the role's permission set.
A note on Spring's naming
In Spring Security, both roles and permissions are represented as GrantedAuthority objects. The only real distinction is a convention: roles are authorities prefixed with ROLE_. When you use helpers like hasRole("ADMIN"), Spring automatically prepends ROLE_, so it checks for the authority ROLE_ADMIN. When you use hasAuthority("document:read"), no prefix is added.
Modeling Roles and Permissions
A clean domain model separates users, roles, and permissions so the relationships are explicit.
@Entity
public class Permission {
@Id @GeneratedValue
private Long id;
private String name; // e.g. "document:read"
}
@Entity
public class Role {
@Id @GeneratedValue
private Long id;
private String name; // e.g. "ROLE_EDITOR"
@ManyToMany(fetch = FetchType.EAGER)
private Set<Permission> permissions = new HashSet<>();
}
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String username;
private String password;
@ManyToMany(fetch = FetchType.EAGER)
private Set<Role> roles = new HashSet<>();
}
This structure gives you a many-to-many relationship in both directions: users can have multiple roles, and roles can share permissions.
Mapping to Spring Security Authorities
Spring Security needs your user's roles and permissions expressed as GrantedAuthority objects. The key step is flattening both roles and their nested permissions into a single authority collection.
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<GrantedAuthority> authorities = new HashSet<>();
for (Role role : user.getRoles()) {
// Add the role itself (e.g. ROLE_EDITOR)
authorities.add(new SimpleGrantedAuthority(role.getName()));
// Add each permission the role grants (e.g. article:write)
for (Permission permission : role.getPermissions()) {
authorities.add(new SimpleGrantedAuthority(permission.getName()));
}
}
return authorities;
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public String getPassword() {
return user.getPassword();
}
// Remaining UserDetails methods (account status flags) omitted for brevity
}
Design note: Flattening permissions into the authority set at load time means your authorization checks later are simple string comparisonsβno lazy loading or extra queries during request handling.
Approach 1: URL-Based Authorization
The broadest form of access control is securing endpoints at the HTTP layer in your SecurityFilterChain.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// Role-based: only admins can reach admin endpoints
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Permission-based: fine-grained control on a specific action
.requestMatchers(HttpMethod.DELETE, "/api/documents/**")
.hasAuthority("document:delete")
// Multiple roles allowed
.requestMatchers("/api/reports/**").hasAnyRole("ADMIN", "MANAGER")
.anyRequest().authenticated());
return http.build();
}
URL-based rules are great for broad strokes, but they live far from your business logic and can't easily express rules like "users can only edit their own documents." For that, we need method-level security.
Approach 2: Method-Level Authorization
Method security lets you place authorization rules directly on service or controller methods. First, enable it:
@Configuration
@EnableMethodSecurity // enables @PreAuthorize, @PostAuthorize, etc.
public class MethodSecurityConfig {
}
@PreAuthorize β check before the method runs
This is the most common and flexible annotation. It evaluates a SpEL expression before invocation.
@Service
public class DocumentService {
// Role-based check
@PreAuthorize("hasRole('ADMIN')")
public void deleteAllDocuments() {
// ...
}
// Permission-based check
@PreAuthorize("hasAuthority('document:write')")
public Document createDocument(DocumentRequest request) {
// ...
}
// Combining checks
@PreAuthorize("hasRole('MANAGER') and hasAuthority('report:export')")
public byte[] exportReport(Long reportId) {
// ...
}
}
Using method arguments in expressions
The real power of @PreAuthorize is referencing method parameters, enabling ownership-style rules.
// Allow if the user has the permission AND owns the resource
@PreAuthorize("hasAuthority('document:edit') and #document.ownerId == authentication.principal.id")
public Document updateDocument(Document document) {
// ...
}
@PostAuthorize β check after the method runs
Sometimes you can only decide once you've seen the resultβfor example, checking ownership of a fetched entity.
@PostAuthorize("returnObject.ownerId == authentication.principal.id")
public Document getDocument(Long id) {
return documentRepository.findById(id).orElseThrow();
}
Caution: @PostAuthorize runs the method first and only then blocks the return value. Never use it for operations with side effects (like deletes)βthe work is already done by the time authorization fails.
Approach 3: Custom Permission Logic
When expressions get complex or repetitive, extract the logic into a reusable bean and call it from SpEL.
@Component("documentSecurity")
public class DocumentSecurity {
public boolean canEdit(Long documentId, Authentication authentication) {
// Custom logic: ownership, shared access, org membership, etc.
Long userId = ((UserDetailsImpl) authentication.getPrincipal()).getId();
return documentRepository.isOwnerOrCollaborator(documentId, userId);
}
}
Reference it in an annotation:
@PreAuthorize("@documentSecurity.canEdit(#id, authentication)")
public Document updateDocument(Long id, DocumentRequest request) {
// ...
}
This keeps complex rules testable, reusable, and out of your annotations.
Connecting to the Frontend
Authorization must be enforced on the backendβthe frontend check is purely for UX. Never rely on hiding a button as your security boundary; a determined user can still call the API directly.
That said, exposing the user's permissions to React lets you tailor the UI. Return authorities from an endpoint like /auth/me:
{
"username": "jane",
"roles": ["ROLE_EDITOR"],
"permissions": ["article:read", "article:write", "article:publish"]
}
Then gate UI elements with a small helper:
import { useAuth } from "./AuthContext";
export function usePermission(permission) {
const { user } = useAuth();
return user?.permissions?.includes(permission) ?? false;
}
// Usage in a component
function PublishButton() {
const canPublish = usePermission("article:publish");
if (!canPublish) return null;
return <button>Publish</button>;
}
Reminder: This only improves the experience. The backend @PreAuthorize check is what actually protects the action.
Common Pitfalls to Avoid
| Pitfall | Consequence | Fix |
|---|---|---|
| Trusting frontend checks only | Trivial privilege escalation | Always enforce on the backend |
Forgetting the ROLE_ prefix |
hasRole checks silently fail |
Let Spring add the prefix; store authorities consistently |
Using @PostAuthorize on side-effecting methods |
Action runs before the check | Use @PreAuthorize for mutations |
| Hardcoding roles in many places | Brittle, hard to change | Model roles as bundles of permissions |
| Overly coarse roles | Can't express fine-grained rules | Add permission-level authorities |
| Not enabling method security | Annotations silently ignored | Add @EnableMethodSecurity
|
Bringing It Together
A robust authorization layer uses the right tool at each level:
- URL-based rules for broad, endpoint-level gating.
-
Method-level
@PreAuthorizefor business-rule and ownership checks. - Custom security beans for complex, reusable logic.
- Roles as bundles of permissions for maintainable, flexible access models.
- Frontend checks for UX only, with the backend as the true boundary.
Roles give you convenient, coarse categories; permissions give you precise control. Most real applications benefit from using both together.
A Note on Verification
The code samples here are illustrative and follow common, modern Spring Security patterns. Because these APIs evolve across versions:
- Confirm the method-security annotation setup for your version.
@EnableMethodSecurityis the current approach, but older code may use@EnableGlobalMethodSecuritywith different attributes. - Verify SpEL expression support and the exact
principaltype available in yourauthenticationobject. - Test both the allow and deny paths for every protected methodβauthorization bugs often hide in the cases you didn't test.
If you'd like, I can expand this into a focused follow-up on testing authorization rules or integrating an external identity provider (like Keycloak) for centralized RBAC. Let me know.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.