Policy Enforcement Points in Microservices
In a microservice estate, the question is never just “what is the policy?” but “where, exactly, does the deny happen?” Get the placement wrong and a forged claim or a missed edge route hands an attacker lateral movement across services. This walkthrough is part of the Advanced Access Control & Authorization guide, and it maps the classic XACML enforcement model — PEP, PDP, PAP, PIP — onto real deployment topologies: an API gateway, a service-mesh sidecar, and in-process middleware, each calling a centralized decision engine while enforcing locally and failing closed.
The vocabulary comes from the XACML reference architecture (OASIS), and it is worth pinning down precisely because every authorization product reuses it:
- PEP — Policy Enforcement Point. The chokepoint that intercepts a request, asks “is this allowed?”, and enforces the answer by passing the call through or returning
403. The PEP is code you place in the request path; it never decides policy itself. - PDP — Policy Decision Point. The engine that evaluates policy against the request context and returns
permit/deny. This is Open Policy Agent (OPA) or Cedar in most modern stacks. - PAP — Policy Administration Point. Where policies are authored, versioned, and distributed (your Git repo plus the OPA bundle server or Cedar policy store).
- PIP — Policy Information Point. The source of attributes the PDP needs but the request does not carry — group membership, resource ownership, tenant tier — fetched at decision time or pushed into the PDP as data.
The architectural rule that follows from this split: decide centrally, enforce everywhere. One PDP gives you a single, auditable source of truth; many PEPs guarantee that no request reaches business logic without passing a check.
The enforcement model end to end
flowchart LR
C["Client / SPA"]:::client -->|request + token| PEP["PEP\nGateway or Sidecar"]:::client
PEP -->|"input: sub, res, action, ctx"| PDP["PDP\nOPA / Cedar"]:::idp
PIP["PIP\nGroups, Ownership"]:::store -->|attributes| PDP
PAP["PAP\nGit + Bundle Server"]:::store -->|signed policy bundle| PDP
PDP -->|permit / deny| PEP
PEP -->|"permit -> forward"| RES["Resource Service"]:::rs
PEP -->|"deny -> 403"| C
classDef client fill:#fff0ee,stroke:#c0392b,stroke-width:2px,color:#1a1614
classDef idp fill:#eef0ff,stroke:#2c3e8c,stroke-width:2px,color:#1a1614
classDef store fill:#fffbec,stroke:#d4840a,stroke-width:2px,color:#1a1614
classDef rs fill:#ebf5fb,stroke:#2980b9,stroke-width:2px,color:#1a1614
The PEP builds an input document (subject, resource, action, environment), the PDP evaluates loaded policy plus PIP-supplied attributes, and the PEP enforces the verdict. The PAP feeds policy in out-of-band as signed bundles, so a policy change never requires a code deploy.
Prerequisites
Before wiring enforcement points, confirm you have:
- Authenticated identity, separated from authorization. A validated token (or mTLS identity) must already be present at the PEP. Authorization decides what an already-authenticated principal may do; never conflate the two. Tokens are verified with an explicit algorithm allowlist (
algorithms: ["RS256"]) before any claim is trusted. - A canonical request schema. Every PEP must produce the same
inputshape so one set of policies covers all services. Decide the field names forsubject,resource,action, andcontextup front. - A running PDP — an OPA sidecar/host or an embedded Cedar evaluator — reachable over loopback or the mesh with sub-10ms p50 latency.
- A policy pipeline (PAP). Policies live in Git, are linted and tested in CI, and ship as signed bundles. Treat policy like code, per the Open Policy Agent for AuthZ guide.
- A model decision. Know whether you are enforcing roles, attributes, or both — see choosing between RBAC and ABAC before encoding rules, because it dictates what your PIP must supply.
Problem framing: where does the deny happen?
A single enforcement layer always leaves a gap. Three placements each cover a different class of request, and production systems usually combine them:
| PEP location | Enforces | Strengths | Blind spots |
|---|---|---|---|
| API gateway / edge | North–south traffic from clients | One place, coarse-grained, catches anonymous/edge traffic | Cannot see east–west calls; lacks fine resource context |
Service-mesh sidecar (Envoy ext_authz) |
Every service-to-service hop | Language-agnostic, enforces east–west, no app code | Operates on HTTP metadata; weak on row-level/object context |
| In-app middleware | Object- and field-level access | Full business context, row-level checks | Per-language, easy to forget a route, in-process trust |
Enforcing only at the edge is the classic failure: once a request is past the gateway, any service it can reach over the network is implicitly trusted, so a single SSRF or a compromised pod has free rein. Defense in depth means the gateway rejects unauthenticated and obviously-forbidden traffic, the mesh re-checks every hop, and the service itself enforces the fine-grained, data-dependent rules that only it has the context for.
Step-by-step implementation
Phase 1 — Build a canonical decision client
Every PEP shares one client that constructs the input document and calls the PDP. Centralizing it keeps the schema consistent and gives you one place to add caching, timeouts, and fail-closed behavior.
// authz-client.ts
export interface AuthzInput {
subject: { id: string; roles: string[]; tenant: string };
resource: { type: string; id?: string; ownerId?: string };
action: string; // "read" | "write" | "delete" | ...
context: { ip: string; method: string; path: string };
}
const PDP_URL = process.env.PDP_URL ?? "http://127.0.0.1:8181/v1/data/authz/allow";
const DECISION_TIMEOUT_MS = 50;
export async function decide(input: AuthzInput): Promise<boolean> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), DECISION_TIMEOUT_MS);
try {
const res = await fetch(PDP_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ input }),
signal: ctrl.signal,
});
if (!res.ok) return false; // PDP error -> fail closed
const body = (await res.json()) as { result?: boolean };
return body.result === true; // absent/false -> deny
} catch {
return false; // timeout / network -> fail closed
} finally {
clearTimeout(timer);
}
}
The contract is deliberately strict: anything that is not an explicit true is a deny. A missing result, a non-200, an aborted timeout, or a thrown error all return false. This is fail-closed by construction — the safest default a PEP can have.
Phase 2 — In-app middleware PEP
The in-process PEP is where you get full resource context (ownership, tenant, field sensitivity). It builds input from the verified token and the route, never from client-supplied claims.
// pep-middleware.ts
import type { Request, Response, NextFunction } from "express";
import { decide, AuthzInput } from "./authz-client";
export function enforce(resourceType: string, action: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const principal = req.principal; // set by the auth layer, NOT from the body
if (!principal) return res.status(401).end();
const input: AuthzInput = {
subject: { id: principal.sub, roles: principal.roles, tenant: principal.tenant },
resource: { type: resourceType, id: req.params.id, ownerId: req.resourceOwnerId },
action,
context: { ip: req.ip, method: req.method, path: req.path },
};
const allowed = await decide(input);
if (!allowed) {
// Log the denial with a decision id for audit; never leak why.
req.log.warn({ sub: principal.sub, action, resourceType }, "authz deny");
return res.status(403).json({ error: "forbidden" });
}
return next();
};
}
// usage
// app.patch("/orders/:id", loadOrderOwner, enforce("order", "write"), updateOrder);
Crucially, subject.roles and subject.tenant come from the cryptographically verified token (req.principal), not from the request body or a header the client can set. Trusting client-asserted claims is one of the most common privilege-escalation bugs — covered in middleware patterns for permission validation.
Phase 3 — Sidecar / gateway PEP with Envoy ext_authz
For east–west traffic and language-agnostic enforcement, run OPA as an Envoy external authorization filter. Envoy calls the sidecar on every request and forwards only on permit.
# envoy ext_authz filter (excerpt)
http_filters:
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
transport_api_version: V3
grpc_service:
envoy_grpc: { cluster_name: opa-sidecar }
failure_mode_allow: false # PDP unreachable -> DENY (fail closed)
with_request_body:
max_request_bytes: 8192
allow_partial_message: true
failure_mode_allow: false is the single most important line: it makes Envoy deny when the OPA sidecar is unavailable. The default in many ext_authz examples is fail-open, which silently disables authorization during a PDP outage.
Phase 4 — Centralized PDP policy (OPA / Cedar)
The same policy serves every PEP. With OPA, a deny-by-default Rego module evaluates the shared input:
# authz/policy.rego
package authz
default allow := false
# Tenant isolation: a subject may only touch its own tenant's resources.
allow if {
input.subject.tenant == data.resources[input.resource.id].tenant
permitted_action
}
permitted_action if {
some role in input.subject.roles
input.action in data.role_actions[role]
}
# Owners may always read/write their own object (ABAC overlay on RBAC).
allow if {
input.resource.ownerId == input.subject.id
input.action in {"read", "write"}
}
Cedar expresses the same intent as typed permit policies and is a good fit when you want a schema-validated policy language with formal analysis; OPA/Rego wins when policy must join against arbitrary external data. Either way the PEPs are identical — only the PDP changes. For attribute-heavy rules, see writing ABAC policies with Cedar.
Phase 5 — Add decision caching
A PDP round trip per request adds latency to every call. Because most decisions repeat (the same subject hitting the same resource), cache the verdict keyed on the decision-relevant inputs with a short TTL:
// cached-decide.ts
import { decide, AuthzInput } from "./authz-client";
const cache = new Map<string, { allow: boolean; exp: number }>();
const TTL_MS = 5_000; // short: bounds staleness after a permission change
function key(i: AuthzInput): string {
return `${i.subject.id}|${i.subject.tenant}|${i.resource.type}:${i.resource.id ?? "*"}|${i.action}`;
}
export async function cachedDecide(input: AuthzInput): Promise<boolean> {
const k = key(input);
const hit = cache.get(k);
const now = Date.now();
if (hit && hit.exp > now) return hit.allow;
const allow = await decide(input);
cache.set(k, { allow, exp: now + TTL_MS });
return allow;
}
Caching turns a hot authorization path from a network call into a map lookup, but it trades freshness for latency — a revoked permission stays “permit” until the entry expires. Doing this safely (cache keys, TTLs, invalidation on permission change, and OPA partial evaluation) is involved enough that it gets its own walkthrough: caching authorization decisions at the API gateway.
Validation & testing
-
Verify fail-closed under PDP outage. Stop the OPA sidecar and replay a normally-permitted request. It must return
403/deny, not200. Repeat for the Envoy filter withfailure_mode_allow: false.# PDP down -> must be denied docker stop opa-sidecar curl -s -o /dev/null -w "%{http_code}\n" -X PATCH \ -H "Authorization: Bearer $TOKEN" https://api.local/orders/ord_1 # expect: 403 -
Test the policy directly with
opa test. Unit-testallow/deny with craftedinputdocuments, including cross-tenant access and owner overrides. -
Prove the edge isn’t the only PEP. From inside the mesh, call a service directly (bypassing the gateway) with a token lacking the right role; the sidecar/in-app PEP must still deny.
-
Confirm claims aren’t trusted from the body. Send a request whose body asserts
roles: ["admin"]; the decision must ignore it becausesubject.rolesis read from the verified token only.
Common misconfigurations
| Symptom | Root cause | Fix |
|---|---|---|
| Auth silently disabled during an incident | PEP or ext_authz fails open on PDP timeout/error |
Default every error path to deny; set failure_mode_allow: false and return false on any non-permit |
| User escalates by editing the request | Decision reads roles/tenant from the body or a client header | Build input.subject only from the verified token; treat all client input as data, never as identity |
| Internal SSRF reaches privileged services | Enforcement only at the gateway; east–west traffic implicitly trusted | Add sidecar/in-app PEPs so every hop is checked; adopt zero-trust between services |
| Revoked role still works for minutes | Decision cache TTL too long with no invalidation | Use short TTLs and invalidate on permission change (see the caching guide) |
| Inconsistent denies across services | Each PEP builds a different input shape |
Centralize the decision client and the input schema; one PDP, one contract |
Security implications
The PEP/PDP split is a direct implementation of least privilege at every boundary, which OWASP ASVS V4 (Access Control) requires: authorization must be enforced server-side at each resource, not assumed from a prior check. Centralizing decisions in a PDP also satisfies auditability — every permit/deny carries a decision ID you can log to a SIEM, which a scatter of hand-rolled if (user.role === "admin") checks never can.
Three threats dominate. Fail-open turns a PDP outage into an authorization bypass; always default to deny. Trusting client claims lets a caller assert its own roles; bind subject to a token validated with an explicit algorithm allowlist and reject alg: none. Edge-only enforcement assumes the internal network is trusted; in a zero-trust model every hop re-authorizes, which is exactly why the sidecar PEP exists. Layer all three — gateway, mesh, and in-app — so that compromising one does not collapse the whole control. When you add caching to cut PDP latency, make the staleness window short and explicit so a revocation takes effect in seconds, not minutes.
Related
- Caching authorization decisions at the API gateway — cache keys, TTLs, invalidation, and OPA partial evaluation for low-latency enforcement.
- Integrating Open Policy Agent for AuthZ — running OPA as the PDP with signed bundles and decision logging.
- Middleware patterns for permission validation — building the in-app PEP and reading identity only from verified tokens.
- Choosing between RBAC and ABAC — picking the model your PDP and PIP must implement.
- Writing ABAC policies with Cedar — expressing attribute-based rules in a typed policy language.