Readable Zero Trust Policies for User, Device, and Context Access
For developers designing access control beyond simple RBAC, this guide shows how to combine user, device, and request context without producing an unmaintainable policy mess. You’ll get a concrete mental model, a worked end-to-end example, and policy/config patterns you can adapt in OPA, Rego, and reverse-proxy enforcement points.
TL;DR — Combining user, device, and context in one access decision works best when you split policy into small named predicates, evaluate them at a single policy decision point, and return both a decision and the reason codes. The most likely fix for unreadable policies is to stop writing giant inline boolean expressions and instead model access as: identity checks + device posture checks + request context checks + action-specific rules. Reading time: ~7 min
What it is and where it sits
In Zero Trust terms, this is policy-based authorization that evaluates who the user is, what device they are on, and under what conditions the request is happening. The hard part is not expressing any one of those. The hard part is combining them without ending up with a 200-character if statement that nobody can safely change.
Architecturally, this usually sits between authentication and the app’s business logic:
- an identity provider or auth gateway authenticates the user and issues claims
- a device management, endpoint, or certificate system contributes posture facts
- the reverse proxy, API gateway, service mesh, or app middleware collects request context
- a policy decision point evaluates the facts
- a policy enforcement point allows, denies, or requires step-up auth
What it replaces:
- hard-coded role checks like
if user.is_admin - IP allowlists embedded in nginx or app code
- scattered “special case” middleware per endpoint
- duplicated authorization logic across services
Typical request flow:
View as text
[User Device] ---- 1. HTTPS request ----> [Proxy/PEP] [Proxy/PEP] ---- 2. validate token ----> [IdP] [Proxy/PEP] ---- 3. fetch posture ----> [Device Facts] [Proxy/PEP] ---- 4. input JSON ----> [Policy Engine/PDP] [Policy Engine/PDP] ---- 5. allow/deny + reasons ----> [Proxy/PEP] [Proxy/PEP] ---- 6. forward or 403 ----> [App]
The important design choice: keep the policy decision point (PDP) separate from the policy enforcement point (PEP). The proxy, gateway, or middleware enforces; the policy engine decides. That separation is what keeps policies readable and testable.
How it actually works
Use one normalized input document for every decision. Do not let each service invent its own field names. A practical shape looks like this:
{
"subject": {
"id": "u_123",
"groups": ["engineering", "prod-oncall"],
"authn_method": "webauthn",
"session_age_seconds": 4200
},
"device": {
"id": "d_456",
"managed": true,
"compliant": true,
"disk_encrypted": true,
"os": "macos",
"os_version": "14.6",
"client_cert_present": true
},
"request": {
"method": "POST",
"path": "/admin/payroll/export",
"ip": "203.0.113.10",
"country": "DE",
"time_utc": "2026-10-30T09:15:00Z"
},
"resource": {
"type": "payroll_export",
"env": "prod"
}
}
One realistic end-to-end example
Requirement:
- allow
POST /admin/payroll/exportonly for users infinanceorhr-admin - require a managed and compliant device
- require phishing-resistant auth (
webauthnor client certificate) - deny from outside business hours unless the user is in
prod-oncall - deny from countries outside an approved set
Readable policy structure:
- define small helper rules:
user_allowed,device_trusted,strong_auth,within_hours,geo_allowed - define one action-specific rule:
allow_payroll_export - return
allow,deny, andreasons
At request time:
- nginx or Envoy terminates TLS and validates the bearer token or forwards verified claims from your auth layer.
- Device facts come from one source only. Common options are mTLS client cert presence, a signed device header from a trusted access proxy, or a lookup keyed by device ID. Do not trust arbitrary client-supplied headers from the public internet.
- The proxy sends the normalized JSON to the policy engine.
- The policy engine evaluates the helpers and the final rule.
- The proxy either forwards to the app or returns
403with an internal reason code in logs.
Example deny response at the edge:
$ curl -i -X POST https://api.example.com/admin/payroll/export \
-H 'Authorization: Bearer eyJ...' \
-H 'X-Device-Id: d_456'
HTTP/1.1 403 Forbidden
content-type: application/json
x-request-id: 7d4c1a8e4b8a
content-length: 86
{"error":"access_denied","reason":"device_not_compliant","request_id":"7d4c1a8e4b8a"}
And the corresponding policy decision log shape:
{
"request_id": "7d4c1a8e4b8a",
"decision": "deny",
"policy": "payroll_export",
"reasons": ["device_not_compliant"],
"subject": "u_123",
"device": "d_456",
"path": "/admin/payroll/export"
}
That reasons array is not cosmetic. It is how you stop policy debugging from turning into guesswork.
When to use it (and when not to)
Use combined user/device/context policy when the resource is sensitive enough that identity alone is not sufficient.
| Scenario | Recommendation |
|---|---|
| Internal admin tools touching prod data or controls | Use combined policy. Require managed device and strong auth. |
| Customer-facing app with low-risk read-only endpoints | Usually keep it simple: auth + app authorization. Device posture is often overkill. |
| Regulated data access: payroll, PHI, financial exports | Use combined policy with explicit reason codes and audit logs. |
| Service-to-service API inside one trusted workload platform | Prefer workload identity, mTLS, and service authz. User/device context may not exist. |
| Small app with one team and three roles | You probably don’t need a policy engine yet. Start with app-level authorization and tests. |
| Remote access from unmanaged BYOD devices | Use combined policy if the business accepts the friction; otherwise isolate data behind VDI/browser isolation instead. |
You probably don’t need this if:
- every request already comes from a non-human workload with strong workload identity
- your risk model does not justify device posture collection
- you cannot get trustworthy device facts and would only be checking spoofable headers
- your team is not prepared to test and version policies like code
Trade-offs
Every benefit here has a bill attached.
- Better risk reduction: stolen credentials alone are less useful.
- Cost: more moving parts: IdP claims, device inventory/posture, policy engine, proxy integration.
- Centralized authorization: fewer one-off checks in app code.
- Cost: policy language learning curve and possible bottleneck around one central team.
- Consistent decisions across services: same rule everywhere.
- Cost: schema discipline. If teams disagree on input fields, policies rot fast.
- Auditable decisions: reason codes and decision logs help incident response.
- Cost: logging sensitive attributes creates privacy and retention obligations.
- Context-aware controls: time, geography, auth strength, environment.
- Cost: false positives. Travel, VPN egress changes, and stale device posture can lock out legitimate users.
- Decoupling policy from app code: easier updates without redeploying every service.
- Cost: another dependency in the request path, usually adding a few milliseconds and an availability concern.
- Vendor neutrality if you use generic components.
- Cost: the more you depend on proprietary device posture signals, the harder migration becomes.
Latency guidance: if the policy engine must call out to external systems during each request, you built it wrong. Pre-resolve facts at the edge, cache short-lived posture data, and pass one input document into a pure policy evaluation step.
In practice
Example 1: Rego policy with readable helper rules
package authz
default allow := false
default reasons := []
allowed_countries := {"DE", "FR", "NL"}
strong_methods := {"webauthn", "mtls"}
user_allowed if {
some g in input.subject.groups
g == "finance" or g == "hr-admin"
}
oncall_override if {
"prod-oncall" in input.subject.groups
}
device_trusted if {
input.device.managed == true
input.device.compliant == true
input.device.disk_encrypted == true
}
strong_auth if {
input.subject.authn_method in strong_methods
} else if {
input.device.client_cert_present == true
}
within_hours if {
t := time.parse_rfc3339_ns(input.request.time_utc)
h := time.clock([t, "Europe/Berlin"])[0]
h >= 8
h < 18
}
geo_allowed if {
input.request.country in allowed_countries
}
allow if {
input.request.method == "POST"
input.request.path == "/admin/payroll/export"
input.resource.env == "prod"
user_allowed
device_trusted
strong_auth
geo_allowed
within_hours or oncall_override
}
reasons contains "user_not_authorized" if not user_allowed
reasons contains "device_not_trusted" if not device_trusted
reasons contains "strong_auth_required" if not strong_auth
reasons contains "geo_not_allowed" if not geo_allowed
reasons contains "outside_business_hours" if not within_hours and not oncall_override
This keeps each condition named and testable. The gotcha: avoid embedding expensive lookups or external calls in policy evaluation; pass all facts in input.
Run it locally:
$ opa eval -f pretty -d authz.rego -i input.json 'data.authz.allow'
true
$ opa eval -f pretty -d authz.rego -i bad-input.json 'data.authz.reasons'
[
"device_not_trusted",
"strong_auth_required"
]
If you see parse errors, they are usually line-specific and actionable:
$ opa eval -d authz.rego 'data.authz.allow'
1 error occurred: authz.rego:27: rego_parse_error: unexpected identifier token
Example 2: nginx auth_request to a policy service
server {
listen 443 ssl http2;
server_name api.example.com;
location = /_authz {
internal;
proxy_pass http://127.0.0.1:8181/v1/data/authz/allow;
proxy_method POST;
proxy_set_header Content-Type application/json;
proxy_set_body '{
"subject": {
"id": "$jwt_claim_sub",
"groups": $jwt_claim_groups,
"authn_method": "$jwt_claim_amr"
},
"device": {
"managed": "$http_x_device_managed",
"compliant": "$http_x_device_compliant",
"client_cert_present": "$ssl_client_verify"
},
"request": {
"method": "$request_method",
"path": "$uri",
"ip": "$remote_addr",
"country": "$geoip2_data_country_code",
"time_utc": "$time_iso8601"
},
"resource": {
"env": "prod"
}
}';
proxy_pass_request_body off;
}
location /admin/payroll/export {
auth_request /_authz;
proxy_pass http://app_backend;
}
}
This enforces the decision before the app sees the request. The gotcha: nginx variables are strings; if your policy expects booleans, normalize them in the auth service or use a sidecar that constructs valid JSON types.
Diagnostic output when the subrequest is failing instead of denying cleanly:
$ curl -I https://api.example.com/admin/payroll/export
HTTP/1.1 500 Internal Server Error
server: nginx
content-type: text/html
Check the nginx error log for the usual shape:
2026/10/30 09:22:41 [error] 4123#4123: *991 auth request unexpected status: 500 while sending to client, client: 203.0.113.10, server: api.example.com, request: "POST /admin/payroll/export HTTP/2.0"
That means your policy endpoint failed; it is not a policy deny. Treat PDP failures explicitly. For sensitive routes, fail closed. For low-risk routes, you may choose fail open, but do it intentionally and log it.
⚠️ If you switch an existing admin route from app-local authorization to edge-enforced deny-by-default, deploy in shadow mode first or you can lock out all operators.
A safe rollout sequence:
# 1. Evaluate policy without enforcing; log decisions only.
# 2. Compare allow/deny against current app behavior for a week.
# 3. Enforce on one route.
# 4. Expand route by route.
Further reading
- OPA Documentation — Policy Language
- OPA Documentation — Policy Testing
- NIST SP 800-207 Zero Trust Architecture
- RFC 9334 OAuth 2.0 Step Up Authentication Challenge Protocol
- MDN HTTP Docs — Authorization
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