Using Cerbos PDP with the Java Spring Security Framework

Published by Twain Taylor on August 12, 2025
Using Cerbos PDP with the Java Spring Security Framework

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.

Prerequisites

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:

  • Java 11+
  • Your preferred build tool (Maven or Gradle)
  • Docker (for running the Cerbos instance locally, so make sure it's installed and ready to go)

You'll also want some familiarity with:

  • Spring Boot and Spring Security basics
  • Annotations and authentication filters
  • Basic AOP concepts (explained as we go)
  • Differences between authentication and authorization

Setting up the initial Spring Boot app

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:

  • Spring Web
  • Spring Security
  • Spring Data JPA
  • H2 Database (for demo)

Your pom.xml should include these key dependencies:

  • spring-boot-starter-web
  • spring-boot-starter-security
  • spring-boot-starter-data-jpa
  • h2

Spring Security configuration

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;
}

And now, a document repository

@Repository
public interface DocumentRepository extends JpaRepository<Document, Long> {
    List<Document> findByOwner(String owner);
}

Create a basic REST controller:

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

Bringing the Cerbos Java SDK into the fold

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.

To integrate Cerbos Java SDK, first resolve the dependencies:

<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

Creating a helper service

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.

Resource building for authorization

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")); }

Declarative authorization with custom annotations

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.

Creating @CerbosCheck Annotation

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

Implementing the AOP aspect

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.

Securing Endpoints

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.

Writing Cerbos policies

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.

Basic policy (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.

Derived roles (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

Testing time!

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

Conclusion

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