Dynamic Rules for Your App: Getting Started with Open Policy Agent (OPA)

Arun BalchandranArun Balchandran
12 min read

Overview

In today’s modern microservice based applications, policy-based control has become essential for enforcing access rules, managing compliance, and ensuring secure behavior across services. One powerful tool that helps developers build these rules declaratively is a framework called Open Policy Agent (OPA).

If you haven’t heard of OPA before, it’s allows you to decouple policy from code, enabling better manageability, auditing, and testing of rules. In this post, we’ll dive into:

  • What is OPA, Really?

  • How to write your first policy with Rego

  • And how to integrate OPA with a real-world Spring Boot application

  • How it works under the hood?


What is OPA, Really?

OPA (Open Policy Agent) is a powerful policy engine and framework that lets you decouple rules from your application logic. Instead of hardcoding authorization, filtering, or validation logic, you define these rules in a separate, declarative format using OPA's policy language, Rego.

Why does that matter? Because it gives you flexibility. You can modify policies on the fly — no need to restart or redeploy your application.

💡 Imagine the possibilities:

  • Zero-downtime feature toggles

  • Real-time adjustments for system load

  • Rapid adaptation to evolving business rules

And that’s just scratching the surface! With OPA, your system becomes more dynamic, adaptable, and maintainable.

Getting Hands-On with OPA

Prerequisites

Before diving into OPA + Spring Boot, make sure your local environment is set up with the following:

  • Java 17
    The project uses Java 17 as the target JDK for compiling and running the Spring Boot application.

  • Docker
    Required for running the OPA, PostgreSQL, and Redis containers.

Setup Resources

Using WSL?
If you're using WSL like I am, the same steps would work for you as well. Just open your favorite terminal editor (I prefer Powershell), & login to WSL.

Now you can follow along with the commands described in the post.

1. Writing Your First OPA Policy

OPA uses a language called Rego for writing policies. Here's a super simple rule that checks if a user is an admin:

package auth            # this defines the namespace that your OPA policy will be part of

default allow = false   # variable used to track our policy result, with a default value

allow {
  input.role == "admin" # this is the check our policy executes & if true, allow will be set to true
}

Let’s break this policy down, into its different components:

package auth

Every Rego policy belongs to a package — think of it like a namespace.
This line says: "All the rules in this file belong to the auth package."
You’ll refer to this package name when querying OPA, like:
/v1/data/auth


default allow = false

This sets the default value for the allow rule to false (i.e., deny by default).
If no other rule matches, OPA will return false.
Secure by default — always a good policy principle.


allow { input.role == "admin" }

This is the rule logic. It says:

“Allow access only if the input role is admin.”

OPA evaluates the input JSON sent by your application — for example:

{
  "role": "admin"
}

If the condition matches, allow becomes true, and access is granted.


TL;DR: This policy enforces admin-only access, is easy to tweak, with just a few lines of Rego.

2. Playing Around in the Rego Playground

The OPA Playground is a web-based tool where you can:

  • Write and test Rego policies

  • Provide input JSON

  • See decision outputs in real-time

Here’s how the Rego Playground looks like:

You can use the section on the left to add your policy and the section on the right to add inputs for the policy.
Let’s start by taking the previously discussed example & testing that policy on the OPA playground. Copy the given example policy and it’s inputs to the page. Click the Evaluate button to run the evaluate the inputs against the policy.

Sample policy:

package auth

default allow = false

allow {
  input.role == "admin"
}

Example input:

{
  "role": "admin"
}

Example output:

{
    "allow": true
}

The policy sends a response with ‘allow’ as ‘true’ if the user has a role : ‘admin’, else it will return ‘false’.

Now we can build on this to create a simple end to end application.

💡 Tip: Experiment with different values for the role & check the outputs returned. Also, what if you changed ‘allow’ to be called a different name? How does the output change?

3. Integrating OPA in a Spring Boot Application

Let’s implement a simple policy-based authorization mechanism in a Spring Boot app.
For this, I’ve already implemented a simple real world application that has a login page & a simple GET Users endpoint that fetches all the users in the system.

Here’s the sample application that we will be using today: https://github.com/arunbalachandran/OpaPolicySpringBoot

Clone the application & navigate to the folder using your favorite terminal.
If you’re running Windows like me - you can clone this inside WSL.

Step 1: Understand the Architecture

Before we dive in, here’s a quick overview of the key layers in the application:

Core Components

  • Security Layer 🔐
    Handles JWT parsing, Spring Security configuration, and exception handling.

    • JWTAuthenticationFilter, JWTService, SecurityConfiguration
  • Authorization Layer
    Intercepts API requests and checks them with OPA before allowing access.

    • OpaService, CustomAuthorizationManager, MethodSecurityConfig
  • Data Layer 🗄️
    Backed by PostgreSQL (user data) and Redis (caching/session).

    • JPA Repositories for persistence
  • API Layer 🌐
    Controllers exposing endpoints for authentication and user management.

    • Annotated with @PreAuthorize to enforce OPA checks

Step 2: Bring Up the System with Docker Compose & Setup the Database

Let’s bring up the containers:

arunbala@ArunRazer:~/OpaPolicySpringBoot$ docker compose up -d
[+] Building 0.0s (0/0)
[+] Running 4/4
 ✔ Network opapolicyspringboot_default       Created                                                               0.2s
 ✔ Container opapolicyspringboot-redis-1     Started                                                               1.1s
 ✔ Container opapolicyspringboot-opa-1       Started                                                               1.2s
 ✔ Container opapolicyspringboot-postgres-1  Started                                                               1.2s
arunbala@ArunRazer:~/OpaPolicySpringBoot$ docker ps
CONTAINER ID   IMAGE                        COMMAND                  CREATED         STATUS         PORTS                                         NAMES
e988e2324495   redis:latest                 "docker-entrypoint.s…"   7 seconds ago   Up 6 seconds   0.0.0.0:6379->6379/tcp, [::]:6379->6379/tcp   opapolicyspringboot-redis-1
775d30d9fdbf   postgres:14.17               "docker-entrypoint.s…"   7 seconds ago   Up 6 seconds   0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp   opapolicyspringboot-postgres-1
82eec0a4c6ad   openpolicyagent/opa:latest   "/opa run --server -…"   7 seconds ago   Up 6 seconds   0.0.0.0:8181->8181/tcp, [::]:8181->8181/tcp   opapolicyspringboot-opa-1

What’s Running?

If you look at the docker-compose file, these are the services running underneath the hood:

  • opa: The Open Policy Agent server on port 8181 — responsible for evaluating policy decisions.

  • postgres: Stores user data for the app.

  • redis: Handles token-related caching and quick lookups.

The Spring Boot app will connect to these services using configurations defined in application.yml.

You can also connect to the database using any database tool of your choice. I prefer using DBeaver.


Step 3: Open the Project in Intellij

Once the Docker containers are up and running, open the project in IntelliJ IDEA.

Make sure you do the following configuration:

Configure Gradle JVM to Java 17

  1. Go to File > Project Structure

  2. Under Project SDK, select Java 17

  3. Then, go to Settings > Build, Execution, Deployment > Build Tools > Gradle

  4. Set Gradle JVM to the same Java 17 SDK

Note: If you don't set Java 17, you may run into class incompatibility or build failures (e.g., Unsupported class file major version 61).

📸 IntelliJ Gradle settings with Java 17

Once this is set up, you can run the Spring Boot app using the Gradle Task

Or if using the terminal:

./gradlew bootRun

The application should start on http://localhost:8080.

Step 3: Upload Your First Policy to OPA

We’ll upload a simple policy where only users with the ADMIN role are allowed using the ‘Create a new Policy’ API

Note: If using Postman, import the collection included in the repository into Postman & you can follow along using the endpoints mentioned here.

# Create a new policy
curl --location --request PUT 'http://localhost:8181/v1/policies/auth' \
--header 'Content-Type: text/plain' \
--data 'package auth

default allow = false

allow if {
  input.role == "ADMIN"
}'

# output
200 OK

You should get ‘200 OK’ response that indicates that the policy was uploaded successfully.

Note: This policy will be queried inside your app via the OpaService, which sends input (e.g., user role) to OPA and receives an allow/deny response.


Step 4: Verify the Policy with a Sample Input

Run the ‘Evaluate auth’ API to verify that the uploaded policy works by passing in a sample input:

# Evaluate auth
curl --location --request POST 'http://localhost:8181/v1/data/auth/allow' \
--header 'Content-Type: application/json' \
--data-raw '{
  "input": {
    "role": "REG_USER"
  }
}'

# output
{
    "result": {
        "allow": true
    }
}

This is what happens internally when you hit a protected endpoint — the user’s role is passed to OPA via OpaService.


Step 5: Register a New User

Let’s create a new user through the /signup endpoint:

# Signup
curl --location 'http://localhost:8080/api/v1/signup' \
--header 'Content-Type: application/json' \
--data-raw '{
  "name": "Arun",
  "email": "arun@test.com",
  "password": "test1234"
}'

# output
{
    "id": "273aef4d-7f95-44d6-be4b-09788c8cdc71",
    "name": "Arun",
    "email": "arun@test.com",
    "role": "REG_USER"
}

The user is stored in PostgreSQL with the default role: REG_USER.

📸 User with REG_USER role in the database:


Step 6: Log In and Get Tokens

Authenticate with the new user to get JWT tokens:

curl --location 'http://localhost:8080/api/v1/auth/login' --header 'Content-Type: application/json' --data-raw '{
    "email": "arun@test.com",
    "password": "test1234"
}' -v
# output body
{
    "id": "c1340964-029a-4a08-aa4b-b08c786b59e8",
    "name": "Arun",
    "email": "arun@test.com",
    "role": "REG_USER"
}

# output headers
{
  ...
  "access_token": "eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjpbIlJFR19VU0VSIl0sInN1YiI6ImFydW5AdGVzdC5jb20iLCJpYXQiOjE3NDYzODQzMzYsImV4cCI6MTc0NjM4NDQ1Nn0.8xL0BQO3HJ5E0US4LqnZach3mRrAVADeluIF4ZXp0IM",
  "refresh_token": "eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjpbIlJFR19VU0VSIl0sInN1YiI6ImFydW5AdGVzdC5jb20iLCJpYXQiOjE3NDYzODQzMzYsImV4cCI6MTc0Njk4OTEzNn0.wW_9MH6Dtw2j3pHUTYuBhgQJLdAfcjrn1zO8s0N-rtA"
  ...
}

The JWTAuthenticationFilter parses this token and sets the user's identity in the security context.

Copy the ‘access_token’ from this step in the headers section as we will use it for the next section. This token will be used for the authorization checks.


Step 7: Try Accessing a Protected Endpoint

Now try hitting /api/v1/users with your access token:

curl --location 'http://localhost:8080/api/v1/users' \
--header 'Authorization: Bearer <your_access_token>'

Because you're a REG_USER, and our policy only allows ADMIN, you’ll get an error like:

{
  "message": "Access Denied"
}

Note: The CustomAuthorizationManager kicks in here and uses OpaService to verify authorization before continuing to the service method called by the controller.


Step 8: Update the Policy to Allow REG_USER

Let’s update the policy on-the-fly to allow users with the REG_USER role:

curl --location --request PUT 'http://localhost:8181/v1/policies/auth' \
--header 'Content-Type: text/plain' \
--data 'package auth

default allow = false

allow if {
  input.role == "REG_USER"
}'

No need to restart the app — OPA will now respond with true for REG_USER!


Step 9: Retry the /users Endpoint

Use your same access token again:

curl --location 'http://localhost:8080/api/v1/users' \
--header 'Authorization: Bearer <your_access_token>'

# output
[
    {
        "id": "c1340964-029a-4a08-aa4b-b08c786b59e8",
        "name": "Arun",
        "email": "arun@test.com",
        "role": "REG_USER"
    }
]

Now you should get a list of users!

📸 Postman screenshot:


Under the Hood: Custom Method-Level Authorization with OPA

In our application, we've implemented fine-grained, OPA-backed authorization at the method level using custom Spring Security expressions. Let’s break down the two key players:


CustomMethodSecurityExpressionRoot

public class CustomMethodSecurityExpressionRoot extends SecurityExpressionRoot implements MethodSecurityExpressionOperations {
    ...
    ...

    public boolean hasAuthorization(String authToken) {
        return opaService.checkPermission(authToken);
    }
}

This class is where custom logic for method security is injected. Specifically, it defines the method hasAuthorization, which is used in @PreAuthorize annotations like:

@PreAuthorize("hasAuthorization(#authToken)")
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<UserDTO>> findAll(
        @RequestHeader(Constants.AUTHORIZATION_HEADER) String authToken
) {
    List<User> users = userService.findAll();
    return new ResponseEntity<>(users.stream().map(UserDTO::mapToDto).toList(), HttpStatus.OK);
}

What this does:

  • hasAuthorization(...) receives the bearer token from the incoming request.

  • It delegates the actual permission check to the OpaService, which calls OPA to make a decision.

So this is your entry point into the OPA ecosystem for each protected endpoint.


OpaService: The Policy Decision Interface

@Service
@Slf4j
public class OpaService {
    ...
    public boolean checkPermission(String token) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        Map<String, Object> input = new HashMap<>();
        input.put(OPA_INPUT, Map.of(ROLE, jwtService.extractClaim(preParseToken(token), val -> val.get(ROLE, List.class)).get(0)));
        HttpEntity<Map<String, Object>> request = new HttpEntity<>(input, headers);
        ResponseEntity<Map> response = restTemplate.postForEntity(
            opaUrl + "/v1/data/auth",
            request,
            Map.class
        );

        if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
            return Boolean.TRUE.equals(((Map<String, String>)response.getBody().get(RESULT)).get(ALLOW));
        }
        return false;
    }
}

This service acts as the communication bridge between your Spring Boot app and the OPA server.

Let’s walk through what happens inside checkPermission(...) step by step:


Step 1: Clean the Token

private String preParseToken(String token) {
    return token.replace(BEARER_PREFIX, "");
}

Since bearer tokens often look like Bearer <JWT>, this method trims off the "Bearer " prefix so we get the raw JWT string.


Step 2: Extract the Role from JWT

jwtService.extractClaim(preParseToken(token), val -> val.get(ROLE, List.class)).get(0)

We:

  • Decode the JWT using JWTService

  • Pull out the role claim (which might be a list, like ["ADMIN"])

  • Use only the first role for now (could be extended later)

This extracted role becomes part of the input sent to OPA.


Step 3: Build the OPA Input Payload

{
  "input": {
    "role": "ADMIN"
  }
}

This input is what the OPA policy will evaluate. It’s wrapped in a JSON map and sent with the request.


Step 4: Make the OPA Call

ResponseEntity<Map> response = restTemplate.postForEntity(
    opaUrl + "/v1/data/auth",
    request,
    Map.class
);

We POST the input to the OPA /v1/data/auth endpoint and wait for a decision.


Step 5: Interpret the Response

return Boolean.TRUE.equals(((Map<String, String>)response.getBody().get(RESULT)).get(ALLOW));

OPA responds with something like:

{
  "result": {
    "allow": true
  }
}

If "allow" is true, we return true — access granted.
If not, access is denied.


Connecting It All

Here’s how the flow works in practice:

  1. User sends a request with a Bearer token.

  2. Controller method is protected with @PreAuthorize("hasAuthorization(#authToken)")

  3. CustomMethodSecurityExpressionRoot.hasAuthorization(...) is triggered.

  4. OpaService.checkPermission(...) builds the input, queries OPA, and returns the result.

  5. Based on the response, Spring either allows or blocks the request.


Flowchart

To better illustrate the flow, here's a visual representation of the authorization process:

sequenceDiagram
    participant User
    participant Controller
    participant CustomMethodSecurityExpressionRoot
    participant OpaService
    participant OPA

    User->>Controller: Sends request with Bearer token
    Controller->>CustomMethodSecurityExpressionRoot: @PreAuthorize("hasAuthorization(#authToken)")
    CustomMethodSecurityExpressionRoot->>OpaService: checkPermission(authToken)
    OpaService->>JWTService: extractClaim(token)
    JWTService-->>OpaService: role(s)
    OpaService->>OPA: POST /v1/data/auth with input.role
    OPA-->>OpaService: { "result": { "allow": true/false } }
    OpaService-->>CustomMethodSecurityExpressionRoot: true/false
    CustomMethodSecurityExpressionRoot-->>Controller: true/false
    Controller-->>User: Access granted/denied

This flowchart demonstrates the sequence of interactions between the components involved in the authorization process.


Wrapping It Up

You’ve now seen how to:

  • Launch an OPA-backed system using Docker integrated with SpringBoot

  • Write a Policy based authorization flow

  • Update policies on the fly without downtime

Next up: Try writing more advanced policies that check for multiple roles, query database-backed attributes, or using policies to drive feature flags!

You can externalize and evolve policies without changing your code!

Got ideas or questions? Drop them in the comments! 💬

0
Subscribe to my newsletter

Read articles from Arun Balchandran directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Arun Balchandran
Arun Balchandran