An Open Policy Agent (OPA) instance that answers authorisation queries is a production access control system, and most of its risk sits in the plumbing around the policy rather than in the Rego itself. Whoever controls the bundle server, the discovery bundle or the OPA management API can change who is allowed to do what, and decision logs can quietly copy tokens and personal data into a log pipeline. This guide is the operational follow-on to the Policy-as-Code for Authorisation Guide, which explains why to externalise decisions and how Rego compares with Cedar. Here the focus is running OPA as a policy decision point (PDP): choosing a deployment topology, enforcing bundle signatures, masking and dropping decision log fields, testing Rego with coverage gates, and monitoring the fleet. It is written for platform engineers, application security teams and identity architects who already run, or are about to run, OPA in front of real traffic.
This is an independent guide from NHI Mgmt Group, based on the Open Policy Agent project's public documentation as of November 2026. It is not sponsored or reviewed by the OPA project. Features change, so confirm settings against the current OPA documentation.
Key takeaways
- Signature enforcement needs a signing block. Top-level
keysalone still activates unsigned bundles; only abundles[_].signingblock (or--verification-keyfor filesystem bundles) makes a missing.signatures.jsona failure. - Decision logs are a data store, not just telemetry. Each event carries the full
inputandresult, so write asystem.log.maskpolicy before enabling upload, and watch the drop counters so audit gaps are visible. - Topology decides your failure mode. A sidecar keeps latency low and failure local; a central PDP simplifies management but turns OPA into a shared dependency that every service must handle failing closed.
- Tests are a release gate. Run
opa testwith--fail-on-empty,--coverageand--thresholdin CI, and write negative tests for every permit rule. - Readiness should mean "policy loaded". Use
/health?bundlesor asystem.healthready rule so traffic never reaches an OPA that has started but has no policy.
Deployment topology: sidecar, host daemon, central service or library
OPA keeps policy and reference data in memory and evaluates locally, which is why the project documents running it close to the service: as a sidecar, a host-level daemon or a Go library. The choice is a security decision because it determines the blast radius of a compromised or unavailable PDP and how many identities can reach the management API.
| Topology | Strengths | Security considerations | Fits |
|---|---|---|---|
| Sidecar per workload | Lowest latency, failure contained to one pod, API reachable on localhost only | Many instances to configure and monitor; each needs bundle service credentials; bundle activation lag differs per pod | Microservices and service mesh external authorisation |
| Host-level daemon | Fewer instances, one config per node | Every process on the host can reach it; API authorisation matters more; a crash affects all tenants on the node | VM estates, SSH or sudo decisions, node-level agents |
| Central PDP service | Single place to update and observe; easy to share data | Network hop on every decision, shared outage, must be exposed beyond localhost with TLS and API authentication, larger target | Low-volume back-office apps, early pilots |
| Embedded Go library (SDK) | No network hop or separate process; manual triggers for bundle and log plugins available | Policy engine shares the application's memory and identity; upgrades ride the application release | Go services, CLIs, gateways built in Go |
Decision guidance: start with sidecars where you already run a mesh or Kubernetes, because the localhost binding default (since OPA 1.0) and pod isolation give you a small attack surface for free. Choose a central PDP only when decision volume is modest and the calling services can tolerate a network dependency; if you do, treat it like any tier 1 internal API, with mutual TLS, autoscaling and an explicit fail-closed behaviour in every client. Small teams with one or two services can run a central instance safely if it is not exposed outside the cluster. See the Service Mesh Security Guide and the API Gateway Security Guide for the enforcement point side.
Where data comes from
The OPA external data documentation lists options in order of preference: data carried in JWTs, data the caller adds to input, data shipped in bundles, data pushed over the API, and data pulled with http.send at evaluation time. Each moves the trust boundary:
- Input overloading: the PEP decides what attributes OPA sees, so a compromised or buggy caller can supply favourable attributes. Validate tokens inside policy (for example with
io.jwt.decode_verify) rather than trusting pre-decoded claims. - Bundles: policy and data arrive together and are consistent, but freshness is bounded by build and polling intervals, and the whole dataset sits in memory.
- Push: anyone with write access to
/v1/datacan change decisions, so the management API must be authorised. http.send: data is fresh, but the documentation notes that if the remote service is slow or unavailable OPA may not return a decision, and the built-ins do not retry. Credentials for that call should come from files or the environment, never from policy source.
Bundle integrity: signing, scoping and distribution
Bundles are the documented, preferred way to update policy across many instances. A bundle is a gzipped tarball with a .manifest (revision and roots), policy and data files. Signing adds a .signatures.json containing a JWT that lists every file with its hash. On receipt, OPA verifies the JWT signature, checks that the file list matches exactly, and checks every file hash; only then does it activate the bundle. On failure it keeps the previous bundle and reports an activation error through the status API and logs.
| Configuration | Unsigned bundle arrives | Signed bundle arrives |
|---|---|---|
| No keys, no signing block | Activated | Fails: no verification key |
Top-level keys only | Activated, warning logged at start-up | Verified |
bundles[_].signing block, or --verification-key | Fails: missing .signatures.json | Verified |
The middle row is the trap: teams configure keys, see signed bundles verify, and assume enforcement is on. An attacker with write access to the bundle server can still publish an unsigned bundle and have it activated. Any signing block, even an empty one, turns enforcement on; keyid only selects the key.
- Signing in CI:
opa build -bwith--signing-keysigns the bundle; the default algorithm is RS256 and--signing-algselects another. Use--claims-fileto add ascopeclaim, and set the samescopein the OPA signing block so a bundle signed for one environment is rejected in another. - Key custody: keep the private key in a KMS or HSM backed signer in the pipeline, not on developer laptops. OPA supports custom signer and verifier plugins for cloud key services, registered under the same plugin key. See the Secrets Management Guide.
- Roots: declare
rootsin each manifest so bundles from different sources cannot overwrite each other's paths; OPA validates scoped bundles on load. - Persistence:
persist: truelets OPA load the last activated bundle from disk (by default under./.opa/bundles/) if the bundle server is unreachable at start-up. That improves availability, but the persistence directory becomes part of your trust boundary: mount it read-write only for the OPA user. - Delta bundles: delta bundles do not support signing, and an OPA configured to verify signatures rejects them unless
allow_unsigned_delta_bundlesis set. Leave that option off for authorisation policy. - Transport and pull credentials: serve bundles over HTTPS, give each OPA a read-only credential for its own resource path, and load tokens with
--set-fileor a credentials file, not command-line arguments. OCI registries are also supported as a bundle source. - Discovery bundles: a discovery bundle generates the rest of OPA's configuration, including which bundles to download and where logs go. It deserves the same signing (
discovery.signing) and change control as policy, because changing it can redirect an entire fleet.
Note that opa eval and opa test do not verify bundle signatures, so a passing CI run proves nothing about what production will accept. Verify the signed artefact in a staging OPA with the production signing configuration. See the CI/CD Security Guide and the Sigstore Guide for pipeline provenance.
Decision logs: privacy, completeness and retention
When decision logging is enabled, each event records the queried path, the full input, the result, the bundle revisions, the requester address, a timestamp and a decision_id that OPA also returns to the caller. Events go to a remote HTTP service (gzipped JSON, default resource /logs), a custom plugin, or the console. That is exactly the audit trail you need to answer "why was this allowed", and exactly the kind of record that leaks bearer tokens, session cookies and personal data if input includes raw request headers.
Masking and dropping
Before encoding and uploading, OPA evaluates data.system.log.mask (configurable with decision_logs.mask_decision). The mask policy receives the whole event as input and returns JSON Pointers to erase, or objects with op of remove or upsert to replace values. Pointers must start with /input, /result or /nd_builtin_cache, and the event records which paths were erased or masked. A minimal policy:
package system.log
mask contains "/input/headers/authorization"
mask contains "/input/headers/cookie"
mask contains {"op": "upsert", "path": "/input/user/email", "value": "**REDACTED**"} if {
input.input.user.email
}
A separate drop rule (path set by decision_logs.drop_decision, default /system/log/drop) removes whole events, for example high-volume health checks. Use drop sparingly: every dropped allow decision is a decision you cannot reconstruct later. The nd_builtin_cache field records outputs of non-deterministic built-ins such as http.send for replay, so mask it too if those responses carry personal data.
Buffering and loss
| Setting | Default | What to decide |
|---|---|---|
decision_logs.reporting.buffer_type | size | size or event buffering |
buffer_size_limit_events | 10000 (event buffer) | Old events are dropped when exceeded |
buffer_size_limit_bytes | unlimited (size buffer) | Cap memory; mutually exclusive with max_decisions_per_second |
max_decisions_per_second | not set | Rate limit; excess events are dropped |
upload_size_limit_bytes | 32768 | Maximum gzipped chunk size |
min_delay_seconds / max_delay_seconds | 300 / 600 | Upload interval, or use trigger: immediate |
Every limit is a deliberate trade between memory and audit completeness. Decide which you prefer per service, then alert on the drop counters described under detection. Ship logs to a store with restricted read access and a retention period your privacy team has approved; see the Privacy in Security Logging and Monitoring Guide. Leave decision_logs.console off in production unless the console stream is itself treated as sensitive.
Hardening the OPA server
The policy-as-code guide covers the core flags; the production points that are most often missed are these:
- API authorisation policy: with
--authorization=basic, OPA evaluatesdata.system.authz.allowfor every API call. The documented hardened example allows onlyPOST /(the default decision) and denies everything else, which blocks policy and data writes over the API when bundles are the only update path. - Separate diagnostics:
--diagnostic-addrexposes only/healthand/metricson a second port, so monitoring does not require exposing the main API. The documentation notes those endpoints remain on the main listener too. - Least privilege: run as a non-root user in its own account, use TLS even on localhost TCP or a UNIX domain socket, and restrict permissions on token files, which are stored in plaintext.
- Provenance: the
provenancequery parameter returns build and bundle revision details, which helps prove which policy produced a decision during an investigation. - Built-in capabilities: restrict which built-ins policy may call (for example disallow
http.sendin domains that should never make network calls) using a capabilities file at build time.
Testing Rego as a release gate
Tests are Rego rules prefixed test_, conventionally in a package ending _test, and the with keyword mocks input, data, rules and even built-in functions. Build the gate in layers:
- Static checks:
opa fmtandopa checkon every commit, plus a linter such as Regal. - Unit tests with a floor:
opa test --fail-on-empty --coverage --threshold 90fails the build if no tests ran or coverage falls under the threshold.todo_tests are reported as skipped; do not let skipped tests accumulate. - Negative and boundary cases: for each allow rule, test the wrong tenant, a missing attribute, an expired token, an unexpected HTTP method and the undefined path. Undefined is not false, so assert on the enforcement point's interpretation, not only on the rule.
- Data-driven tables: keep a table of principal, action, resource and expected decision owned by the business, and run it as parameterised tests. Use
--var-valuesto see why a case failed. - Performance budget:
opa test --benchandopa benchmeasure evaluation cost; the performance documentation recommends objects keyed by ID over arrays and indexable equality statements. Fail the build on a large regression, because a slow PDP becomes an availability problem. - Shadow comparison: before promoting a major policy change, replay recent decision logs (inputs are already masked) against the new bundle and review every flipped decision.
Rolling out and rolling back
- Inventory: list every OPA instance, its topology, bundle sources, discovery use, and which services call it. Unknown OPAs with authorisation off are the first finding in most reviews.
- Harden one ring: turn on API authorisation, TLS and diagnostic separation on a pilot service; confirm health checks and the PEP still work.
- Enable signing in observe mode: start signing bundles in CI while OPA has top-level keys only, confirm every instance verifies signed bundles (status shows the expected
active_revision), then add thesigningblock ring by ring. - Turn on masked decision logging: deploy the mask policy first, sample a day of events, confirm no secrets remain, then enable upload.
- Gate readiness: point readiness probes at
/health?bundles(or asystem.healthready rule) so new pods take traffic only after policy is active. - Rollback: keep the previous signed bundle revision available on the bundle server. Rolling back means republishing it; if you set
superseded_etag_ttl_seconds, the documentation notes that a bundle replaced within that window is not served again until it has passed, so size the window with rollback in mind. Never roll back by disabling signature verification.
Where identity fits
OPA itself is a non-human identity with privileged reach: its bundle service token can read every policy, its decision log credential can write to your audit store, and any client allowed to write /v1/policies or /v1/data can grant access. Give each OPA a distinct workload identity (for example a SPIFFE ID or a short-lived service account token), scope bundle credentials to one resource path, and rotate them. Authorisation decisions about AI agents and service accounts also depend on the attributes the PEP sends, so prefer verified workload identity over caller-asserted names. See the SPIFFE and SPIRE Guide and the AI Agent Authorisation Guide.
Detection and measurement
Enable the status plugin (and its Prometheus option) so every instance reports bundle state, plugin state and counters centrally. Useful signals from the OPA monitoring and status documentation:
- Bundle activation:
bundle_failed_load_counterand statusbundles[_].codeorerrors; a signature failure here may be an attack or a broken pipeline, and both need a person. - Revision drift: instances whose
active_revisiondiffers from the current published revision longer than your polling window. - Decision log loss:
decision_logs_dropped_rate_limit_exceeded_total,decision_logs_dropped_buffer_size_limit_exceeded_total,decision_logs_encoding_failure_totaland statusdecision_logs.code. - Latency and errors:
http_request_duration_secondsby handler, and the PEP's own count of PDP timeouts. - Unexpected API writes: any successful
PUTto policy or data APIs on an instance meant to be bundle-only.
Metrics that show the programme works: percentage of instances with a signing block enforced (target 100), percentage of decision log events with masking applied to sensitive fields, policy test coverage per bundle, median time from merge to fleet-wide activation, and dropped decision log events per million decisions.
Framework mapping
| Control area | OPA practice | NIST SP 800-53 Rev. 5 | NIST CSF 2.0 |
|---|---|---|---|
| Policy decision and enforcement | Default deny, PEP fails closed | AC-3 Access Enforcement, AC-24 Access Control Decisions | PR.AA-05 |
| Policy change control | Reviewed, tested, signed bundles | CM-3 Configuration Change Control, SI-7 Software, Firmware, and Information Integrity | PR.PS-01 |
| Decision logging | Decision logs with revision and decision ID | AU-2 Event Logging, AU-3 Content of Audit Records | DE.CM-09 |
| Log protection and retention | Masking, restricted store, retention | AU-9 Protection of Audit Information, AU-11 Audit Record Retention | DE.CM-09 |
| Transport | TLS for API, bundles and log upload | SC-8 Transmission Confidentiality and Integrity | PR.DS-02 |
Common failure patterns
- Keys without a signing block: signed bundles verify, unsigned ones are still activated, and nobody notices until a bundle server is compromised.
- Raw headers in input: the PEP forwards the whole request, decision logs fill with bearer tokens, and the log store becomes a credential source.
- Silent audit gaps: rate limits or buffer caps drop events under load and no alert fires on the drop counters.
- Ready before policy: pods pass a plain liveness check and receive traffic before the first bundle activates, so the PEP sees errors or undefined results.
- Central PDP that fails open: a client library treats timeouts as allow to protect availability, turning an OPA outage into an access control bypass.
- Exposed management API:
--addr 0.0.0.0:8181with authorisation off lets any reachable client replace policy over/v1/policies.
Practitioner checklist
- Inventory every OPA instance, its topology, callers and bundle sources.
- Add a
signingblock (anddiscovery.signing) on every instance, with ascopeper environment. - Sign bundles in CI with a KMS or HSM backed key; never sign on laptops.
- Run with
--authorization=basicand a deny-by-defaultsystem.authzpolicy allowing only decision queries. - Deploy a
system.log.maskpolicy before enabling decision log upload; mask tokens, cookies and personal data. - Alert on bundle load failures, revision drift and decision log drop counters.
- Gate CI with
opa test --fail-on-empty --coverage --thresholdand negative test cases. - Point readiness probes at bundle activation, and make every PEP fail closed.
- Keep the previous signed bundle revision ready for rollback.
Standards and references
- Open Policy Agent: Bundles
- Open Policy Agent: Decision Logs
- Open Policy Agent: Security
- Open Policy Agent: Policy Testing
- Open Policy Agent: Status API
- Open Policy Agent: Monitoring
- Open Policy Agent: External Data
- NIST: SP 800-53 Rev. 5
Related NHI Mgmt Group resources: Policy-as-Code for Authorisation Guide · Kubernetes Admission Control Guide · Authorisation Models Guide · Externalised Authorisation Buyer's Guide · Amazon Verified Permissions