Centralized API Access Control with Ory Oathkeeper and OPA Rego Policies
Microservices often duplicate auth logic, leading to inconsistency and maintenance overhead. Ory Oathkeeper can delegate authorization decisions to OPA’s Rego engine, providing a central, policy‑driven access control layer.
06 Sept 2026, 15:27 UTC

Problem: Scattered Authorization Logic in a Microservice Landscape
When each microservice implements its own role‑ or ownership checks, you end up with duplicated code, inconsistent error handling, and a security surface that is hard to audit. Adding a new rule means touching every service, which slows delivery and increases the chance of mistakes.
Thesis: Delegate Authorization to OPA via Ory Oathkeeper
Ory Oathkeeper works as a reverse proxy that separates authentication (who the caller is) from authorization (what they may do). Instead of embedding a Rego engine, Oathkeeper can forward the authorization decision to an external Open Policy Agent (OPA) instance using its remote authorizer. OPA evaluates Rego policies, which are declarative, version‑able, and can read claims from JWTs or call external data sources. This keeps your services free of auth logic while giving you a central place to evolve fine‑grained rules.
Architecture Overview
- Matcher: Determines which access rule applies based on the request path, method, or headers.
- Authenticator: Validates a token (commonly a JWT) and populates the request context with claims.
- Authorizer (remote): Sends the request context to an OPA server; OPA returns an allow/deny decision based on a Rego policy.
- OPA (Rego Engine): Loads one or more Rego policies and evaluates them against the input supplied by Oathkeeper.
- Backend Services: Receive only requests that Oathkeeper has forwarded after a successful authorization check.
Worked Example: Order‑Access Policy
We want to allow GET /orders/{id} only if the caller has the admin role or is the owner of the order. The policy lives in OPA; Oathkeeper merely calls it.
# policy/order_access.rego
package authz.order
# Main rule evaluated by OPA
allow {
input.method == "GET"
input.path[0] == "orders"
input.path[1] == order_id
user_is_admin
# or the user owns the order
user_is_owner(order_id)
}
# Helper: check for admin role in the JWT
user_is_admin {
some i
input.jwt.claims.roles[i] == "admin"
}
# Helper: fetch order owner from an external data source
# OPA will receive this data via an HTTP data source defined in its config.
user_is_owner(order_id) {
owner := data.order_owner[order_id]
owner == input.jwt.claims.sub
}
OPA needs to be able to resolve data.order_owner. In a simple setup you can expose an internal endpoint on your order service that returns JSON like {"owner_id":"user-abc"} and configure OPA to call it as an HTTP data source.
Oathkeeper Configuration (access_rules format)
Below is a minimal config.yaml for Oathkeeper (v0.25.0 used as an example; consult the latest docs for version‑specific fields). It defines a matcher for the orders service, a JWT authenticator, and a remote authorizer that points to OPA running on http://opa:8181.
log:
level: info
format: json
# Matcher defines which upstream service the rule applies to
matchers:
- name: orders
url: http://orders-service:8080
match:
url: ^/orders/.*
# Authenticator validates the JWT
authenticators:
- handler: jwt
config:
jwks_url: https:///.well-known/jwks.json
issuer: https://
audience: orders-service
# Authorizer delegates to OPA
authorizers:
- handler: remote
config:
# OPA’s decision endpoint
url: http://opa:8181/v1/data/authz/order/allow
# HTTP method and headers OPA expects
method: POST
headers:
Content-Type: application/json
# Forward the request context as the OPA input body
forward_body: true
# Consumers bundle matcher, authenticator, and authorizer
access_rules:
- matcher: orders
authenticator:
- jwt
authorizer:
- remote
# No mutators needed for this example
mutator: []
Deploying the Stack with Docker Compose
The following compose file brings up Oathkeeper, OPA, a mock orders service, and a simple JWT issuer (using ory/hydra for token issuance in a real scenario; here we use a static JWT for brevity). Replace placeholders with your actual values.
version: "3.8"
services:
oathkeeper:
image: oryd/oathkeeper:v0.25.0
command: ["serve", "--config", "/etc/oathkeeper/config.yaml"]
volumes:
- ./config.yaml:/etc/oathkeeper/config.yaml
- ./policy:/etc/opa/policy
ports:
- "4444:4444"
depends_on:
- opa
- orders-service
opa:
image: openpolicyagent/opa:0.58.0-rootless
command: ["run", "--server", "--set", "decision_logs.console=true", "/etc/opa/policy"]
volumes:
- ./policy/order_access.rego:/etc/opa/policy/order_access.rego
- ./opa-config.yaml:/etc/opa/opa-config.yaml
ports:
- "8181:8181"
orders-service:
image: hashicorp/http-echo
command: ["-listen", ":8080", "-text", "orders service"]
ports:
- "8080:8080"
# Optional: a simple static JWT generator for testing
token-gen:
image: alpine:latest
command: ["sh", "-c", "while true; do sleep 3600; done"]
The OPA config (opa-config.yaml) can include an HTTP data source to fetch order owners:
# opa-config.yaml
services:
order_owner:
url: http://orders-service:8080/internal/owner
credentials:
bearer:
token: ""
labels:
- order_owner
data:
order_owner:
service: order_owner
path: /_/
# OPA will cache the response; adjust ttl as needed
Testing the Flow
Create a test JWT that includes a sub claim (the user ID) and optionally a roles array. For example, using jq and openssl:
# Payload with sub=user-abc and roles=["admin"]
PAYLOAD=$(echo '{"sub":"user-abc","roles":["admin"],"exp":'$(( $(date +%s) + 3600 ))'}' | jq -c .)
# Header
HEADER=$(echo '{"alg":"HS256","typ":"JWT"}' | jq -c .)
# Base64url encode
b64() { echo -n "$1" | openssl base64 -e | tr -d '=' | tr '+/' '-_' | tr -d '\n'; }
JWT=$(b64 "$HEADER").$(b64 "$PAYLOAD").$(echo -n "$HEADER.$PAYLOAD" | openssl dgst -sha256 -hmac "secret" -binary | b64)
# Call the proxied endpoint
curl -i -H "Authorization: Bearer $JWT" http://localhost:4444/orders/123
If the JWT contains the admin role or the sub matches the owner returned by the order service, you should see a 200 OK from the orders service. Otherwise Oathkeeper returns 403 Forbidden. Enable debug logging in Oathkeeper (log.level: debug) to see the request sent to OPA and the decision returned.
Trade‑off: Added Latency and Learning Curve
- Latency: Each request incurs an extra network hop to OPA and the Rego evaluation time. In low‑traffic APIs the overhead may be a few milliseconds; in high‑throughput environments you should benchmark (e.g., with
wrk) and consider enabling OPA’s built‑in caching or deploying OPA as a sidecar close to Oathkeeper. - Learning Curve: Teams accustomed to imperative authorization code may need time to become comfortable with Rego’s declarative style. Start with small, testable policies and use the
opa evalcommand locally to verify logic before deploying. - Mitigation: Keep policies modular, store them in version control, and monitor OPA’s
decision_logsand Oathkeeper’s metrics (oauthkeeper_authorization_latency_seconds) for regressions.
Actionable Next Steps
- Prototype: Run the compose stack above, write a Rego rule that reflects a real permission in your system, and test with various JWTs.
- Benchmark: Measure latency with and without Oathkeeper/OPA using a tool like
heyorwrk. Aim for an overhead that fits your SLA (e.g., ≤20 ms at the 95th percentile). - Integrate Real Data Sources: Replace the mock
/internal/ownerendpoint with a call to your actual order database or a caching layer. - Version Control Policies: Keep Rego files in a Git repository, tag releases, and configure OPA to load policies from a mounted volume or a bundle server.
- Monitor and Alert: Expose Oathkeeper’s Prometheus metrics and OPA’s decision logs; set alerts on spikes in
403responses or on evaluation times exceeding a threshold.
By using Ory Oathkeeper as a gateway and delegating authorization decisions to OPA’s Rego engine, you centralize fine‑grained API access control without modifying your microservices. The primary costs are a modest increase in latency and the need to learn Rego, both of which can be managed with proper testing, caching, and observability.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.