Granular Authorization in Spring Boot: Beyond Role Checks

Picture this. You were planning a beautiful day, but your product manager sprints to your desk at 9 AM. There’s a “minor” data leak. A junior accountant in the Berlin office just discovered they could view the CEO’s payroll details by changing a userId in the URL.

You look at the code. There’s a glorious, five-year-old if (user.isAdmin()) block at the top of the PayrollService. Simple. Elegant. Terrifying. You realize “Admin” in this system is anyone who can log in and hasn’t forgotten their password three times in a row.

We build fortress-like authentication systems with OAuth2, OIDC, and MFA, then leave the back door open because authorization was treated as an afterthought. Something we could “just fix with an Interceptor later.” Well, later is now a legal department meeting.


The Authorization Lies We Tell Ourselves

Before touching code, let’s name the self-deception that keeps bad authorization alive in production systems.

  • “The frontend will hide the buttons.” This is putting a “Do Not Enter” sign on an unlocked door. You’re trusting a JSON payload to someone with Chrome DevTools. The API has no idea the button was hidden.
  • “Roles are enough.” They never are. The moment a stakeholder says “a manager can see reports, but only for their department, and only if the report isn’t in Draft state” — your role system just died. You’re now writing ManagerOfDepartmentAndReportNotDraftRole. Good luck with that migration script.
  • “Security is a cross-cutting concern — the Platform Team will handle it.” Translation: “I don’t want to think about it and I’m hoping middleware reads minds.” The middleware doesn’t know that Bob from Accounting shouldn’t see Alice’s expense reports. That’s a business rule, and business rules live in your domain layer.

Phase 1: The Scattered If-Statement Approach

This is what authorization looks like when it’s treated as validation. It’s scattered, duplicated, and essentially untestable without mocking the universe.

public void updateProject(Long projectId, ProjectUpdateDto dto, User user) {
Project project = repository.findById(projectId).orElseThrow();
if (!user.getRoles().contains("ADMIN") && !project.getOwnerId().equals(user.getId())) {
throw new AccessDeniedException("You don't own this project");
}
if (project.isLocked() && !user.getRoles().contains("SUPER_USER")) {
throw new AccessDeniedException("Project is locked");
}
project.setName(dto.getName());
repository.save(project);
}

The problem: your business logic is now a hostage. When access rules change — and they always do — you hunt down every service method that has a copy of this logic. And good luck unit testing this without mocking half the application context.

Phase 2: The Over-Abstracted Trap

The developer reads about Clean Code and decides to abstract everything. They create a SecurityStrategyFactory with a PolicyEvaluatorComposite.

public class ProjectSecurityService {
private final List<SecurityRule> rules;
public void checkUpdatePermission(Project project, User user) {
rules.stream()
.filter(rule -> rule.supports(project))
.forEach(rule -> rule.evaluate(project, user));
}
}

You’ve replaced a simple if statement with a polymorphic mystery box. To understand why a user was denied, you now trace through a List<SecurityRule> populated by a DI container configured in a file nobody has opened in six months. More complexity, zero additional power.


The Right Approach: Declarative, Decoupled, Dynamic

The goal is authorization that is declarative (readable at the method signature), decoupled (not embedded in business logic), and dynamic (able to evaluate domain-specific rules). Spring Security’s Method Security gives us exactly that.

Step 1: Use a Role Hierarchy

Stop manually checking for ADMIN and USER in every method. If an Admin is a User with more power, model that relationship explicitly. This single bean eliminates a significant percentage of redundant @PreAuthorize strings in most codebases.

@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.fromHierarchy("""
ROLE_ADMIN > ROLE_MANAGER
ROLE_MANAGER > ROLE_USER
""");
}
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
RoleHierarchy roleHierarchy,
CustomPermissionEvaluator customPermissionEvaluator) {
var handler = new DefaultMethodSecurityExpressionHandler();
handler.setRoleHierarchy(roleHierarchy);
handler.setPermissionEvaluator(customPermissionEvaluator);
return handler;
}

Step 2: Attribute-Based Access Control via SpEL

For ownership checks and simple domain rules, Spring Expression Language keeps the service layer clean. The access policy is visible at the method signature — no digging required.

@PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')")
public List<ProjectResponseDto> findAll() {
return projectRepository.findAll().stream()
.map(this::toResponse)
.toList();
}
@PreAuthorize("hasRole('ADMIN') or #project.ownerId == authentication.principal.id")
public ProjectResponseDto updateProject(Project project) {
return toResponse(projectRepository.save(project));
}

When the logic gets complex enough to require a database call or external service, SpEL one-liners stop being appropriate. That’s what the PermissionEvaluator is for.

Step 3: Custom PermissionEvaluator for Domain Logic

This is the professional approach to domain-specific authorization. One place to answer the question: “Can this principal perform this action on this resource?”

@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission) {
if (!(targetDomainObject instanceof Project project)) {
return false;
}
var requiredPermission = (String) permission;
var user = (UserPrincipal) auth.getPrincipal();
return switch (requiredPermission) {
case "WRITE" -> isOwner(project, user) || hasOrganizationAccess(project, user);
case "READ" -> true;
default -> false;
};
}
private boolean isOwner(Project project, UserPrincipal user) {
return project.getOwnerId() != null
&& project.getOwnerId().equals(user.getId());
}
private boolean hasOrganizationAccess(Project project, UserPrincipal user) {
return user.getOrgId() != null
&& user.getOrgId().equals(project.getOrganizationId())
&& user.getRoles().contains("ROLE_MANAGER");
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId,
String targetType, Object permission) {
return false;
}
}

Your service method now reads like a policy document:

@PreAuthorize("hasPermission(#project, 'WRITE')")
public void secureUpdate(Project project) {
// Pure business logic. No auth checks. No if statements.
}

Why Developers Keep Getting This Wrong

Two failure modes dominate here. The first is Cargo Cult Programming — copying @PreAuthorize("hasRole('ADMIN')") from a tutorial and assuming that’s the ceiling. It’s not. It’s the floor.

The second is Analysis Paralysis. Developers know that scattered if statements are wrong but believe implementing a PermissionEvaluator is too much investment for a simple feature. So they compromise, and six months later that compromise is the foundation of a system nobody wants to touch. The right architecture rarely feels expensive at implementation time — it only feels expensive when you skip it.


On the SpEL Performance Question

If your application is slow because of SpEL expression evaluation, either you’re building a High-Frequency Trading platform on Spring Boot (which raises a different set of questions) or your database queries are so inefficient they’ve warped your sense of what a bottleneck is. For 99% of business applications, the overhead of a PermissionEvaluator is negligible next to a single poorly-indexed SQL query.


Actionable Steps to Clean Up Your Authorization

  1. Audit your controllers and services. Any if statement checking a user ID, role, or ownership doesn’t belong in business logic — move it.
  2. Wire up a RoleHierarchy. Takes five minutes, eliminates a third of your redundant @PreAuthorize strings immediately.
  3. Implement PermissionEvaluator for domain rules. Decouple “Is this the owner?” from “Can I persist this to the database?”
  4. Stop hardcoding permission strings. Create a Permissions constants class. @PreAuthorize("hasPermission(#target, 'WRITE')") is significantly better than discovering a typo in production. Ask me how I know.

Authorization is the art of saying “No” gracefully and consistently. If your code says “Yes” more often than your business rules intend, you’re not a developer — you’re an accomplice.

Go look at your most critical service. If it’s protected by a single role check, you have work to do.


Discover more from The Dev World – Sergio Lema

Subscribe to get the latest posts sent to your email.


Comments

Leave a comment