Designing PDP and PEP for an application platform without auth sprawl
For developers designing an application platform under Zero Trust, this guide explains where policy decision points and policy enforcement points belong, how requests actually flow through them, and what trade-offs you pay for centralizing authorization. You’ll get one realistic end-to-end example plus concrete nginx, Envoy/OPA-style, and application integration snippets you can adapt.
TL;DR — In a Zero Trust platform, put policy decisions in a small number of policy decision points (PDPs) and put enforcement at the edges that already control traffic or data access: API gateways, service mesh sidecars, ingress, job runners, and database access layers. The most common good design is: PEPs collect request context, call a PDP for an allow/deny plus obligations, cache briefly, and fail closed for write paths unless you have a documented break-glass mode. Reading time: ~7 min
What it is and where it sits
A useful mental model is not “authorization library vs external auth service.” It is “where is the decision made?” and “where is it enforced?” In platform terms:
- PDP: the component that evaluates policy from inputs like subject, resource, action, tenant, network posture, and time.
- PEP: the component that can actually stop or shape behavior: return
403, strip a header, redact fields, downgrade scopes, or block a SQL statement.
In a typical application platform, the PEP lives at places that already see requests or data movement:
- ingress proxy or API gateway
- service mesh sidecar
- GraphQL gateway / BFF
- background worker before dequeuing or executing a job
- data access layer before issuing SQL
- object storage download service before minting signed URLs
The PDP usually lives as a network service or embedded engine with centrally managed policy bundles. It replaces scattered if user.role == ... checks across services. It does not replace authentication, session handling, or identity proofing. It consumes their outputs.
What talks to it:
- PEPs send an authorization input document
- policy admins publish policy and data
- observability stack receives decision logs
- CI/CD validates policy bundles before rollout
What it replaces:
- duplicated role checks in each service
- inconsistent edge-vs-app authorization behavior
- ad hoc “admin bypass” code paths nobody can audit
Where it sits in request flow:
View as text
[Client] ---- 1. POST /v1/invoices/123/refund ----> [API Gateway PEP]
[API Gateway PEP] ---- 2. authn result + request attrs ----> [PDP]
[PDP] ---- 3. deny + reason=missing_scope ----> [API Gateway PEP]
[API Gateway PEP] ---- 4. 403 JSON error ----> [Client]
|
| 5. decision log
v
[Audit/Logs]Under Zero Trust, this matters because network location is not enough. The decision should use verified identity and request context every time, and the enforcement should happen at every trust boundary that matters, not just at the perimeter.
How it actually works
Walk one realistic example: a support engineer in tenant acme tries to refund invoice inv_123 through the platform API.
Step 1: The PEP gathers context
The ingress or API gateway terminates TLS, validates the JWT or session, and constructs an input document. Typical fields:
- subject: user id, groups, scopes, device posture if you have it
- action:
refund - resource:
invoice, idinv_123 - tenant:
acme - environment: prod/staging
- request attributes: source IP, method, path, headers
- fetched attributes: invoice owner tenant, invoice state, amount
This is where teams often under-design. If the PEP sends only user_id and path, the PDP cannot express useful policy without making expensive callback lookups or encoding app logic into policy.
Step 2: The PEP calls the PDP
A typical request is an HTTP POST with JSON. The PDP returns:
allow: true|false- optional obligations: redact fields, max amount, require step-up auth
- optional decision id for audit correlation
- cache hints if supported by your design
Example request body shape:
{
"subject": {
"id": "u_42",
"groups": ["support"],
"scopes": ["invoice:read", "invoice:refund"],
"tenant": "acme"
},
"resource": {
"type": "invoice",
"id": "inv_123",
"tenant": "acme",
"state": "paid",
"amount": 12900,
"currency": "USD"
},
"action": "refund",
"request": {
"method": "POST",
"path": "/v1/invoices/inv_123/refund",
"ip": "203.0.113.10"
},
"environment": {
"service": "billing-api",
"stage": "prod",
"time": "2026-10-30T12:00:00Z"
}
}
Step 3: The PDP evaluates policy
A realistic policy might say:
- user must have
invoice:refund - subject tenant must equal resource tenant
- invoice must be
paid - support can refund up to
$100; finance can refund any amount - refunds over
$1000require step-up auth
The PDP returns deny because amount is $129.00? No, that is 12900 cents, so $129.00; support is over the $100 limit. The response could be:
{
"allow": false,
"decision_id": "dec-7f3d9b",
"reason": "amount_exceeds_role_limit",
"http_status": 403,
"obligations": {
"max_refund_amount": 10000,
"currency": "USD"
}
}
Step 4: The PEP enforces, not the app
The gateway returns 403 before the request hits billing-api. That is the point: the app does not need to know every platform-wide rule.
Typical response shape:
$ curl -i -X POST https://api.example.com/v1/invoices/inv_123/refund \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{"amount":12900}'
HTTP/2 403
content-type: application/json
x-decision-id: dec-7f3d9b
x-request-id: req-a1c2
content-length: 121
{"error":"forbidden","reason":"amount_exceeds_role_limit","details":{"max_refund_amount":10000,"currency":"USD"}}
If allowed, the PEP forwards the request and may attach trusted headers like x-authz-decision-id and x-authz-subject. Do not let upstream clients set these directly; strip them first at the edge.
Step 5: Logging and cache behavior
The PEP logs the decision id and request id. For read-heavy paths, it may cache allow/deny for a few seconds keyed on subject/resource/action plus relevant attributes. For write paths, keep TTLs short and invalidate on role change if you can.
A common failure mode is stale allow after revocation. If your cache TTL is 60 seconds, your revocation SLA is at least 60 seconds unless you push invalidations.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| Multiple services need consistent authz rules | Use centralized PDP with distributed PEPs |
| You need auditability of who could access what and why | Use PDP/PEP; log decision ids and policy version |
| Single small app with one codebase and simple roles | Keep authz in-app first; add a policy engine only if rules start diverging |
| You need field-level redaction or query shaping | Use PEP close to the data/API boundary, not only at ingress |
| You have hard real-time latency budgets in the low milliseconds | Prefer embedded PDP or precomputed entitlements; network PDP calls may be too expensive |
| Policies depend heavily on app-specific business logic | Keep that logic in the app; use PDP for coarse-grained platform rules |
| You cannot tolerate fail-closed outages on reads | Use local policy bundles or cached decisions; avoid synchronous remote-only PDP on hot paths |
You probably do not need a separate PDP service if all of these are true:
- one app, one team, one deployment unit
- fewer than a dozen stable permissions
- no cross-service consistency problem
- no external audit/compliance pressure
- no need to enforce outside the app process
Trade-offs
Every benefit has a bill.
- Consistent decisions across services → costs policy modeling work. You must define a common input schema and resource taxonomy. That is platform design, not just config.
- Central audit trail → costs log volume and privacy review. Decision logs can contain user ids, tenant ids, and resource metadata.
- Fast policy changes without app redeploys → costs policy rollout discipline. A bad policy bundle can break production everywhere at once.
- Zero Trust enforcement at multiple boundaries → costs operational sprawl. You now run policy distribution, PDP health checks, cache invalidation, and policy tests.
- Reduced auth code in apps → costs harder debugging. A
403might come from ingress, sidecar, app middleware, or data layer. Standardize headers likex-decision-id. - Fine-grained authorization → costs latency. Attribute fetches are often slower than policy evaluation itself.
- Vendor neutrality if you use generic HTTP/JSON policy APIs or standard policy languages → costs building glue yourself. Vendor products often package admin UI, bundles, and logs together.
The biggest design choice is remote PDP vs embedded PDP.
- Remote PDP: simpler central control, easier audit, but adds network hops and failure modes.
- Embedded PDP: lower latency and better resilience, but policy distribution/version skew becomes your problem.
In practice
Example 1: nginx as a PEP using subrequests
This pattern is common when nginx already fronts your APIs. auth_request sends a subrequest to an internal location that proxies to the PDP. Any 2xx allows; 401/403 denies.
server {
listen 443 ssl http2;
server_name api.example.com;
location /v1/ {
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Method $request_method;
proxy_set_header Authorization $http_authorization;
auth_request /_authz;
auth_request_set $authz_decision_id $upstream_http_x_decision_id;
proxy_set_header X-Authz-Decision-Id $authz_decision_id;
proxy_pass http://billing_api;
}
location = /_authz {
internal;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Content-Type "application/json";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Method $request_method;
proxy_set_header Authorization $http_authorization;
proxy_pass http://pdp_authorizer/check;
}
}
What it does: blocks unauthorized requests before they hit the upstream app. Gotcha: auth_request only sees the subrequest response code and headers; if you want a JSON error body from the PDP, you usually need error_page 403 = @forbidden_json; and generate the body in nginx or via another internal location.
A broken setup often looks like this:
$ curl -i https://api.example.com/v1/invoices/inv_123/refund
HTTP/2 500
server: nginx
content-type: text/html
<html>...500 Internal Server Error...</html>
Check nginx error log for upstream auth failures:
$ tail -n 5 /var/log/nginx/error.log
2026/10/30 12:04:22 [error] 4123#4123: *991 auth request unexpected status: 502 while sending to client, client: 203.0.113.10, server: api.example.com, request: "POST /v1/invoices/inv_123/refund HTTP/2.0", host: "api.example.com"
That means the PEP failed to contact the PDP. Decide now whether this path fails closed or uses a break-glass route; do not discover that during an incident.
Example 2: OPA-style policy and a direct test call
This example uses Rego syntax because it is widely known and easy to test locally. The same design applies if your PDP uses another engine.
package platform.authz
default allow := false
tenant_match if {
input.subject.tenant == input.resource.tenant
}
has_scope(scope) if {
scope in input.subject.scopes
}
role_limit := 10000 if "support" in input.subject.groups
role_limit := 100000000 if "finance" in input.subject.groups
allow if {
input.action == "refund"
input.resource.type == "invoice"
input.resource.state == "paid"
has_scope("invoice:refund")
tenant_match
input.resource.amount <= role_limit
}
deny_reason := "amount_exceeds_role_limit" if {
input.action == "refund"
input.resource.amount > role_limit
}
What it does: expresses a tenant boundary and role-based refund limit with a default deny. Gotcha: keep units explicit. Here amount is cents; if one service sends dollars and another sends cents, your policy is “correct” and your system is wrong.
Test it locally:
$ opa eval -f pretty -d authz.rego -I 'data.platform.authz.allow' <<'JSON'
{
"subject": {"tenant":"acme","groups":["support"],"scopes":["invoice:refund"]},
"resource": {"type":"invoice","tenant":"acme","state":"paid","amount":12900},
"action": "refund"
}
JSON
false
$ echo $?
0
Important diagnostic detail: opa eval exits 0 even when policy result is false; false is not a process failure. In CI, assert on output content, not only exit code.
Example 3: application-side PEP middleware forwarding decision context
Even with edge enforcement, you often still need app-local enforcement for object-level checks or background jobs.
{
"decision_id": "dec-7f3d9b",
"allow": true,
"obligations": {
"redact_fields": ["card_last4"],
"max_page_size": 100
}
}
What it does: lets the app enforce obligations after a positive decision. Gotcha: “allow” is not the whole answer. If you ignore obligations, you silently weaken policy.
⚠️ If you move an existing platform from in-app checks to edge/sidecar PEPs, stage it route by route. A broad deny policy or a missing trusted-header strip can cause immediate production outage or privilege escalation.
A safe rollout sequence is:
# 1. Log-only mode at the PEP if your stack supports it, or mirror authz calls.
# 2. Enforce on one read-only route.
# 3. Enforce on low-risk write routes.
# 4. Remove duplicate in-app checks only after decision logs match expectations.
Further reading
- NIST SP 800-207 Zero Trust Architecture
- Open Policy Agent documentation, especially Policy Language and REST API
- Envoy documentation, External Authorization filter
- The "Authorization" and "Caching" sections of the MDN HTTP docs
- Google Zanzibar paper
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI