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

Table of contents
- Overview
- What is OPA, Really?
- Getting Hands-On with OPA
- Prerequisites
- 1. Writing Your First OPA Policy
- 2. Playing Around in the Rego Playground
- 3. Integrating OPA in a Spring Boot Application
- Step 1: Understand the Architecture
- Step 2: Bring Up the System with Docker Compose & Setup the Database
- Step 3: Open the Project in Intellij
- Configure Gradle JVM to Java 17
- Step 3: Upload Your First Policy to OPA
- Step 4: Verify the Policy with a Sample Input
- Step 5: Register a New User
- Step 6: Log In and Get Tokens
- Step 7: Try Accessing a Protected Endpoint
- Step 8: Update the Policy to Allow REG_USER
- Step 9: Retry the /users Endpoint
- Under the Hood: Custom Method-Level Authorization with OPA
- Wrapping It Up

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
Install Java 17 using SDKMAN (my preferred option) or you can install Java directly using ‘apt’
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
- Annotated with
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 port8181
— 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
Go to
File > Project Structure
Under Project SDK, select
Java 17
Then, go to
Settings > Build, Execution, Deployment > Build Tools > Gradle
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:
User sends a request with a Bearer token.
Controller method is protected with
@PreAuthorize("hasAuthorization(#authToken)")
CustomMethodSecurityExpressionRoot.hasAuthorization(...)
is triggered.OpaService.checkPermission(...)
builds the input, queries OPA, and returns the result.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! 💬
Subscribe to my newsletter
Read articles from Arun Balchandran directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
