Externalized Authorization: Why Access Logic Doesn't Belong in Your Application Code
Scattered permission checks are a maintenance and security liability. Externalized authorization separates the Policy Enforcement Point from the Policy Decision Point — here is the pattern, the latency objections answered, and a safe migration path.
Search your codebase for if (user.role === and count the hits. Every one is a business rule about who can do what, hard-coded into a handler, duplicated across services, and invisible to your security team. This is embedded authorization, and it does not scale. Externalized authorization is the fix.
The Embedded Authorization Anti-Pattern
Embedded authorization looks harmless in a single handler and catastrophic across a codebase:
// invoices.controller.ts
if (user.role !== 'admin' && user.role !== 'manager') throw new Forbidden()
// reports.controller.ts
if (!user.isAdmin && user.plan !== 'enterprise') throw new Forbidden()
// billing.service.ts
if (user.role === 'viewer') throw new Forbidden() // slightly different rule!The problems compound quickly:
- Inconsistency. The same concept (“can manage billing”) is expressed three different ways.
- No audit story. There is no single place to answer “who can delete invoices?”
- Change friction. A policy change means a code change, a review, and a deploy — per service.
What Externalized Authorization Means
Externalized authorization separates two responsibilities inherited from the XACML reference architecture: the Policy Enforcement Point (PEP) — thin code in your app that asks “is this allowed?” — and the Policy Decision Point (PDP) — a dedicated service that owns the logic and answers. Your application stops knowing the rules and starts asking for decisions.
// The PEP is now identical everywhere — one line, no business logic:
async function requirePermission(userId, scopeId, resource, action) {
const { allowed } = await pdp.hasAccess({ subjectId: userId, scopeId, resource, action })
if (!allowed) throw new Forbidden()
}
// billing.service.ts
await requirePermission(user.id, scopeId, 'billing', 'manage')Why It Wins
- One source of truth. Rules live in the PDP; every service enforces the same decision.
- Auditability. Policy changes are data changes, logged and reviewable — see our access policies model.
- Velocity. Changing who can do what no longer requires a code deploy.
The Latency Objection
“But now every check is a network call.” This is the most common objection, and it is answerable. A well-designed PDP returns decisions in single-digit milliseconds, and request-scoped caching deduplicates repeated checks within a request. We cover the engineering in Engineering Sub-10ms Authorization Checks.
A Safe Migration Path
Use the strangler pattern — do not rewrite everything at once:
- Introduce a single
requirePermissionhelper backed by the PDP. - Replace embedded checks one endpoint at a time, starting with the highest-risk ones.
- Mirror decisions (log-only) before enforcing, to catch behavioral differences safely.
How WardenAuth Fits
WardenAuth is an externalized PDP with a single has-access endpoint. Your services keep a one-line PEP; the roles, permissions, and deny-wins logic live in WardenAuth and are editable without a deploy. Try it free.