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

Enforcement, decision, information and administration points The enforcement point asks the decision point for a verdict; the decision point reads attributes from an information point and policy from a signed bundle produced by the administration point, then permits or denies. Enforcement gateway or sidecar Decision policy engine Information groups · ownership Administration Git · signed bundles Resource service ask permit only
Naming the four roles is what lets a team argue about placement productively: most disagreements are really about which component owns which of these.

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 input shape so one set of policies covers all services. Decide the field names for subject, resource, action, and context up 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, not 200. Repeat for the Envoy filter with failure_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-test allow/deny with crafted input documents, 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 because subject.roles is 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.

Failure Modes by Placement

What breaks when each enforcement point fails A gateway failure blocks all external traffic but leaves internal calls unchecked, a sidecar failure affects only its own service, and an in-process failure is a bug in one code path that may fail open. Gateway down external traffic stops; internal calls unaffected and unchecked Sidecar down one service fails closed — blast radius of one In-process check missing silent — no error, just data returned to the wrong caller
The bottom row is the dangerous one: the other two announce themselves, and a missing in-process check looks exactly like a working system.

Service-to-Service Calls Need Their Own Identity

Propagating both the user and the calling service An internal call carries the calling service's own credential plus the end user's context, so the receiving service can authorise the pair rather than trusting a header. Service A own credential Call carries both service identity + user context Service B authorises the pair A bare "X-User-Id" header is an assertion any pod on the network can make.
Mutual TLS or a signed internal token proves which service is calling; the user context rides alongside it and is never trusted on its own.

Choosing Placement From Your Failure Budget

Placement is usually argued as a philosophy question and is really a failure-mode question. Ask what happens to each option when it breaks, and the answer picks itself.

A gateway-only design means one component decides for everything, so its failure is total and its blind spot — internal traffic — is permanent. It is the right choice when your services are genuinely behind one door and internal calls are trusted by network policy rather than by authorization.

A sidecar per service contains failure to one service and covers internal traffic, at the cost of a process to run, patch and observe alongside every deployment. It is the usual answer once services call each other, because it keeps the decision local and the policy shared.

In-process evaluation is the fastest and the most exposed to human error: the check is code, so a route without it is silently unprotected. It works well for object-level decisions that need the loaded record, which is precisely where the other two placements cannot help.

Most production systems end up with two of the three: a coarse gate at the edge and an object-level check inside the service, both reading policy from the same source. What matters is that the split is deliberate and written down, so nobody assumes the other layer covered a case.

Frequently Asked Questions

Is one enforcement point at the gateway enough?

Only if no service ever calls another and no decision depends on which record is being touched — which is rarely true. The gateway sees a URL and a token, so it can stop unauthenticated and obviously unauthorised traffic, but it cannot know that record 4711 belongs to a different tenant. Pair it with an in-service check on the resolved object.

How should internal service-to-service calls be authorised?

With the calling service’s own verifiable identity — mutual TLS or a signed internal token — plus the end user’s context carried alongside it. The receiving service then authorises the pair: this service, acting for this user, may do this. A plain user identifier in a header is an assertion any workload on the network can make, and treating it as authoritative removes every boundary inside your internal network.

What happens when the decision point is unreachable?

The request is refused. Write that as an explicit policy — deny everything, or serve reads from a cached verdict while refusing writes — and prove it with a chaos test that blocks the engine and asserts the response. The natural-looking error handler that logs and continues turns every engine hiccup into an open door, and it is the single most common serious defect in otherwise careful designs.

Does every service need its own policy bundle?

They should share one source and may load different slices of it. A single policy repository keeps semantics consistent — “owner” means the same thing everywhere — while packaging per domain keeps a change to billing rules from touching document sharing. What you want to avoid is each service maintaining its own interpretation of the same concept.

How do I keep latency acceptable with several enforcement layers?

Make each layer do only what it is placed to do: the gateway checks coarse rules from data it already has, the sidecar evaluates locally with no network hop, and the in-service check reuses the record it just loaded. Measure the decision path separately from the request, budget it explicitly at the 99th percentile, and alert on it — because when authorization gets slow, teams start caching too aggressively or skipping checks they judge harmless.