Authorization logic scattered throughout your Spring application? You're not alone. Most developers usually start with simple role-based checks embedded directly in their controllers and services, but as applications grow, this approach becomes a maintenance nightmare.With hard-coded permissions, scattered business rules, and the constant need to redeploy for policy changes, all you’re left with is a bunch of bottleneck scenarios.
But what if your authorization logic possessed the same fluidity as your business requirements? With Cerbos integrated into your Spring Security setup, complex permission checks become as simple as adding an annotation to your methods.
This guide shows you how to build a seamless integration between the Cerbos Java SDK and Spring Security using Aspect-Oriented Programming (AOP), and in the process, you'll create a clean, annotation-driven approach that keeps controllers focused on business logic.
This tutorial assumes you're comfortable with Spring Boot fundamentals and have worked with Spring Security before. You don't need to be an expert, but understanding concepts like authentication filters, security configurations, and basic annotations will help you follow along smoothly.
We'll also touch on AOP, but we'll explain the concepts as we go. If you've never used it before, don't worry. The patterns we'll use are straightforward and well-documented.
On top of all that, having a basic understanding of authorization versus authentication will serve you well, since we'll be building on Spring Security's authentication capabilities while delegating authorization decisions to Cerbos.
Before diving into the integration, ensure you have the essentials in place:
You'll also want some familiarity with:
Let's create the foundation for our Spring Boot application, for which we'll start with a basic project structure and build from there.
Start by heading to Spring Initializr and generate a new project with Spring Web, Spring Security, and Spring Data JPA dependencies. We'll also need the H2 Database for this demo.
Use Spring Initializr to generate a project with:
Your pom.xml
should include these key dependencies:
spring-boot-starter-web
spring-boot-starter-security
spring-boot-starter-data-jpa
h2
Here's where most developers start feeling the pain. The thing is, traditional Spring Security configurations reveal their limitations quickly. You need different users with varying capabilities, but roles alone aren’t enough:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user").password("password").roles("USER").build();
UserDetails admin = User.withDefaultPasswordEncoder()
.username("admin").password("admin").roles("ADMIN").build();
return new InMemoryUserDetailsManager(user, admin);
}
}
This configuration sets up basic HTTP authentication with two users and protects all endpoints except those under /public/**
.
Now, create a simple Document entity:
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Document {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String owner;
private String department;
}
@Repository
public interface DocumentRepository extends JpaRepository<Document, Long> {
List<Document> findByOwner(String owner);
}
@RestController
@RequestMapping("/api/documents")
public class DocumentController {
private final DocumentRepository documentRepository;
public DocumentController(DocumentRepository documentRepository) {
this.documentRepository = documentRepository;
}
@GetMapping
public List<Document> getAllDocuments() {
return documentRepository.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<Document> getDocument(@PathVariable Long id) {
return documentRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public Document createDocument(@RequestBody Document document, Authentication auth) {
document.setOwner(auth.getName());
return documentRepository.save(document);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteDocument(@PathVariable Long id) {
return documentRepository.findById(id)
.map(doc -> {
documentRepository.delete(doc);
return ResponseEntity.ok().build();
}).orElse(ResponseEntity.notFound().build());
}
}
Right now, any authenticated user can perform any operation on any document. You could try and fix it by adding method-level security annotations, but then you're hardcoding business rules into your application code. What happens when the business decides that managers can edit documents from their department, but only on weekdays? Or when they want to restrict access to confidential documents based on clearance levels?
This is the exact problem that Cerbos solves elegantly, as we'll see in the next section.
Cerbos has taken a long, hard look at the application development timeline, seen how the codebase gets overly complicated as business requirements change, and decided to flip the switch. It operates as a standalone authorization engine that evaluates access decisions based on policies written in human-readable YAML. The Cerbos Policy Decision Point receives requests containing a principal, resource, and action, then evaluates these against your defined policies.
<dependency>
<groupId>dev.cerbos</groupId>
<artifactId>cerbos-sdk-java</artifactId>
<version>0.15.3</version>
</dependency>
The SDK provides a complete Java client for interacting with Cerbos instances, including both blocking and async client implementations, with support for TLS and plain text connections.
Now, create a configuration class to set up the Cerbos client:
@Configuration
public class CerbosConfig {
@Bean
public CerbosBlockingClient cerbosClient() throws CerbosClientBuilder.InvalidClientConfigurationException {
return new CerbosClientBuilder("localhost:3593")
.withPlaintext()
.buildBlockingClient();
}
}
This configuration creates a blocking client that connects to a local Cerbos instance on port 3593. You may notice that the client uses plaintext communication, which is suitable for local development but should be secured with TLS in higher environments.
The next steps require you to create a docker-compose.yml
file in your project root to run Cerbos locally:
version: '3.8'
services:
cerbos:
image: ghcr.io/cerbos/cerbos:0.45.1
ports:
- "3592:3592"
- "3593:3593"
volumes:
- ./cerbos/policies:/policies
command: ["server", "--config=/policies/.cerbos.yaml"]
.cerbos.yaml
server:
httpListenAddr: ":3592"
grpcListenAddr: ":3593"
storage:
driver: "disk"
disk:
directory: /policies
watchForChanges: true
Spring Security stores the current user in a thread-local context. So, let’s create a simple service to extract this information.
@Service
public class CerbosService {
private final CerbosBlockingClient cerbosClient;
public CerbosService(CerbosBlockingClient cerbosClient) {
this.cerbosClient = cerbosClient;
}
public boolean checkAccess(String resourceType, String resourceId, String action,
Map<String, Object> principalAttrs, Map<String, Object> resourceAttrs) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) return false;
// Build principal with attributes - key improvement from official examples
Principal.Builder principalBuilder = Principal.newBuilder()
.withId(auth.getName())
.withRoles(extractUserRoles(auth));
// Add principal attributes for contextual decisions
if (principalAttrs != null) {
principalAttrs.forEach((key, value) -> {
if (value != null) {
principalBuilder.withAttribute(key, convertToAttributeValue(value));
}
});
}
// Build resource with attributes
Resource.Builder resourceBuilder = Resource.newBuilder()
.withKind(resourceType)
.withId(resourceId);
if (resourceAttrs != null) {
resourceAttrs.forEach((key, value) -> {
if (value != null) {
resourceBuilder.withAttribute(key, convertToAttributeValue(value));
}
});
}
try {
CheckResult result = cerbosClient.check(
principalBuilder.build(),
resourceBuilder.build(),
action
);
return result.isAllowed(action);
} catch (Exception e) {
// Log the error but deny access by default
log.error("Cerbos authorization check failed", e);
return false;
}
}
private String[] extractUserRoles(Authentication auth) {
return auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.filter(role -> role.startsWith("ROLE_"))
.map(role -> role.substring(5).toLowerCase())
.toArray(String[]::new);
}
private AttributeValue convertToAttributeValue(Object value) {
if (value instanceof String) {
return AttributeValue.stringValue((String) value);
} else if (value instanceof Number) {
return AttributeValue.longValue(((Number) value).longValue());
} else if (value instanceof Boolean) {
return AttributeValue.boolValue((Boolean) value);
} else {
return AttributeValue.stringValue(value.toString());
}
}
}
This service will encapsulate the entire authorization flow and extract the current user from Spring Security's context, build the necessary Cerbos objects, and go on to make the authorization decision. This bit remains simple even as the power rests in Cerbos policies that can evaluate complex business rules.
For our document management system, we need to represent documents as Cerbos resources, and the key lies in including relevant attributes that policies can evaluate:
private Resource buildDocumentResource(Long documentId, String owner) {
return Resource.newInstance("document", documentId.toString())
.withAttribute("owner", AttributeValue.stringValue(owner)) .withAttribute("department", AttributeValue.stringValue("engineering")); }
Rather than calling the Cerbos service manually in every controller method, custom annotations with AOP create a cleaner, more maintainable approach. This transforms authorization into a declarative concern that's applied consistently across your application.
@CerbosCheck
AnnotationDefine an annotation that captures the essentials of an authorization check:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CerbosCheck {
String resourceType();
String resourceIdParam();
String action();
String[] principalAttrs() default {};
String[] resourceAttrs() default {};
}
This annotation specifies what action is being attempted and what type of resource is being accessed. We default to "document" since that's our primary resource type, but you can customize this for different resources.
Create an aspect that intercepts annotated methods and performs the authorization check:
@Aspect
@Component
@Slf4j
public class CerbosAspect {
private final CerbosService cerbosService;
public CerbosAspect(CerbosService cerbosService) {
this.cerbosService = cerbosService;
}
@Around("@annotation(cerbosCheck)")
public Object authorize(ProceedingJoinPoint joinPoint, CerbosCheck cerbosCheck) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
try {
// Extract resource ID using robust parameter mapping
String resourceId = extractParameterValue(paramNames, args, cerbosCheck.resourceIdParam());
// Extract principal attributes based on annotation configuration
Map<String, Object> principalAttrs = extractAttributeMap(paramNames, args, cerbosCheck.principalAttrs());
// Extract resource attributes based on annotation configuration
Map<String, Object> resourceAttrs = extractAttributeMap(paramNames, args, cerbosCheck.resourceAttrs());
// Perform authorization check
boolean isAuthorized = cerbosService.checkAccess(
cerbosCheck.resourceType(),
resourceId,
cerbosCheck.action(),
principalAttrs,
resourceAttrs
);
if (!isAuthorized) {
log.warn("Access denied for action '{}' on resource '{}:{}'",
cerbosCheck.action(), cerbosCheck.resourceType(), resourceId);
throw new AccessDeniedException(
String.format("Access denied for action '%s' on resource '%s'",
cerbosCheck.action(), cerbosCheck.resourceType())
);
}
return joinPoint.proceed();
} catch (IllegalArgumentException e) {
log.error("Cerbos aspect configuration error: {}", e.getMessage());
throw new AccessDeniedException("Authorization configuration error");
}
}
private String extractParameterValue(String[] paramNames, Object[] args, String targetParam) {
for (int i = 0; i < paramNames.length; i++) {
if (targetParam.equals(paramNames[i])) {
Object value = args[i];
return value != null ? value.toString() : null;
}
}
throw new IllegalArgumentException(
String.format("Required parameter '%s' not found in method signature", targetParam)
);
}
private Map<String, Object> extractAttributeMap(String[] paramNames, Object[] args, String[] attrParams) {
Map<String, Object> attributes = new HashMap<>();
for (String attrParam : attrParams) {
for (int i = 0; i < paramNames.length; i++) {
if (attrParam.equals(paramNames[i])) {
Object value = args[i];
if (value != null) {
attributes.put(attrParam, value);
}
break;
}
}
}
return attributes;
}
}
This aspect intercepts method calls, extracts the resource ID from the method arguments, and calls Cerbos to check permissions. If access is denied, it throws an exception. Alternatively, it allows the method to proceed normally.
Now you can secure your endpoints with simple annotations:
@RestController
@RequestMapping("/api/documents")
public class DocumentController {
@GetMapping("/{id}")
@CerbosCheck(action = "read")
public ResponseEntity<Document> getDocument(@PathVariable Long id) {
return documentRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
@CerbosCheck(action = "delete")
public ResponseEntity<Void> deleteDocument(@PathVariable Long id) {
documentRepository.deleteById(id);
return ResponseEntity.ok().build();
}
}
The declarative nature of this approach makes your authorization requirements explicit and maintainable. When business rules change, you update policies in Cerbos rather than modifying application code.
With your integration complete, the final step involves writing policies that define your authorization rules. These policies transform complex business requirements into readable, maintainable authorization logic.
document.yaml
)apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: document
version: default
rules:
- actions: ["read"]
effect: EFFECT_ALLOW
roles: ["USER"]
- actions: ["create", "update"]
effect: EFFECT_ALLOW
roles: ["USER"]
condition:
match:
expr: request.resource.attr.owner == request.principal.id
- actions: ["delete"]
effect: EFFECT_ALLOW
roles: ["ADMIN"]
With such a policy, users can read any document, but they can only create, update, or delete documents they own. Administrators can delete any document.
document_roles.yaml
)For more sophisticated authorization patterns, leverage derived roles that provide contextual permissions:
apiVersion: api.cerbos.dev/v1
derivedRoles:
name: document_roles
definitions:
- name: owner
parentRoles: ["USER"]
condition:
match:
expr: request.resource.attr.owner == request.principal.id
Now it’s time to test your secured endpoints with different user contexts:
# Test as regular user
curl -u user:password http://localhost:8080/api/documents/1
# Test as admin
curl -u admin:admin -X DELETE http://localhost:8080/api/documents/1
By decoupling authentication and authorization, you empower security teams to define flexible, auditable access control rules while letting developers focus on business logic. Cerbos brings maintainability and agility to your Spring Security setup — exactly what modern teams need to stay ahead.
To understand more about Cerbos, explore the official Cerbos documentation, experiment with the Cerbos AWS Lambda template, and start creating additional policies that include derived roles, conditions, and hierarchical rules.
Once you have done the setup, you can easily integrate Cerbos into your CI/CD pipelines. By version-controlling your policies alongside your infrastructure, you enable your teams to benefit from transparent, testable authorization rules that adapt and grow with your product.
Authorization is hard, but it doesn’t have to stay tangled in your application code. Cerbos offers you an escape route, and combined with AWS Lambda and API Gateway, you can deploy it seamlessly across any cloud environment.
If you want to dive deeper into implementing and managing authorization, join one of our engineering demos or check out our in-depth documentation.
Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team
Join thousands of developers | Features and updates | 1x per month | No spam, just goodies.