Jan 19, 2026 · identity · Evergreen · 18 min read
Building identity guardrails without blocking delivery
Every time a dev tool asks me to create “just one more password”, a small part of me dies.
So yes: I’m genuinely happy we’re moving away from password-per-tool and towards “log in with the thing you already use” (SSO, short-lived sessions, real identity, real auditability).
I’m even happier we’re slowly killing the even worse pattern: “it’s behind the VPN, so it doesn’t need auth”.
The VPN was never an identity system. It was a network location check. And the moment you make an internal tool reachable without the VPN (or expose it more broadly inside the company), you discover an uncomfortable truth:
A tool that lived in a VPN-only bubble is often less battle-tested at the edges.
That’s where “identity guardrails” earn their keep: a small component in front of the app that does the deterministic security work consistently, so the app doesn’t have to be perfect on day one.
I’ve seen enough “SSO-enabled” applications that I still wouldn’t trust them on the open internet.
SSO is not a security review. It tells you who someone is. It doesn’t magically fix a sketchy admin endpoint, a legacy authz model, or an app that treats headers as gospel.
What you get with a guard:
- consistent authn at the perimeter
- coarse “can this hit this path?” authorization
- a smaller blast radius while you harden the app
What you don’t get:
- object-level authorization (IDOR prevention)
- business-rule enforcement
- a free pass to stop fixing the app
If the app serves sensitive data, you still need app-level authz; the guard just buys time and reduces blast radius.
So the guard exists to be simpler than the app, easier to reason about, and easier to audit.
It can be an Istio/Envoy policy layer, an IAP (identity-aware proxy), an edge function, an ALB auth integration, a classic oauth2-proxy in front of NGINX — whatever fits your stack. The point isn’t the brand. The point is the shape.
What the guard is responsible for
If you only take one thing from this: don’t push auth down into every legacy service first. Wrap it, standardize it, then you can fix internals over time.
My baseline for the guard:
- Authenticate with OIDC (and verify tokens properly).
- Authorize (coarse-grained) with explicit perimeter rules (paths/methods/groups), not vibes.
- Reduce attack surface with concrete defaults (method limits, body size caps, sensitive path protection).
- Absorb abuse with boring controls (rate limits, concurrency caps, circuit breakers) so the app doesn’t get melted.
- Log decisions (who/what/why) so you can actually investigate incidents.
If you implement only three things:
- Enforce app reachability only via the guard (plus mTLS/workload identity).
- Validate tokens locally and pin
iss/aud(no “accept anything signed by the IdP”).- If you use opaque tokens, “local validation” becomes introspection/token exchange + caching; keep timeouts tight and avoid per-request online dependencies where possible.
- Strip identity headers and mint an app-scoped artifact (no header fallback).
This doesn’t make the underlying app “secure”. It just makes it much harder to accidentally expose the worst parts of it.
Minimum viable guardrails (the “start here” checklist):
- Authn: validate JWTs locally (pin
iss/aud, enforce expectedalgand rejectnone, allow small clock skew), refresh JWKs; fail closed on invalid/unverifiable tokens- Authz: protect sensitive paths first (
/admin,/debug, exports, “delete all”), then ratchet- Guard → app boundary: enforce “only the guard can reach the app” (network policy / security groups / firewall rules) and ideally use mTLS (or at least authenticated workload identity)
- Trust boundary: strip inbound identity headers (
X-User,X-Email,X-Auth-Request-*) and trustX-Forwarded-*/Forwarded/X-Forwarded-Host/X-Forwarded-Protoonly from known proxies- App contract: the app ignores identity headers entirely; it accepts identity only via a verifiable artifact (minted JWT / token exchange / mTLS identity)
- Observability: log decision + principal + path + reason, and make it queryable
- Proxy parsing: reject ambiguous/invalid HTTP (request smuggling shapes, H2↔H1 translation quirks) instead of trying to “normalize” it
- Safety rails: body size limit, method allowlist, sane timeouts, host allowlist + strict security-header handling (avoid open redirects), rate limits per principal/IP/path
What the guard cannot do
This is where people over-apply the pattern.
An identity guard can do perimeter authorization: “this request can hit this path”. It cannot reliably do resource authorization for most real apps:
- object-level access (tenant boundaries, per-project permissions, row-level access)
- business-rule checks (“you can delete this only if …”)
- “confused deputy” problems where the app uses identity claims incorrectly
If the app has an IDOR bug (“any authenticated user can fetch any object by ID”), a guard won’t save you. It’ll just make the incident show up in nicer logs.
That’s fine. The point of guardrails isn’t to replace app authz. It’s to shrink risk while you improve the app over time.
Perimeter authz is a risk reducer, not a compliance story.
Threat model (what this stops, what it doesn’t)
This pattern is great at stopping the boring, high-frequency failures:
- accidental exposure (“we forgot to protect the new endpoint”)
- missing decorators / inconsistent in-app authn
- unauthenticated admin/debug endpoints
- header-trust incidents at the perimeter
It does not replace app-level authorization:
- IDORs / object-level access control bugs
- SSRF / logic flaws / confused deputy issues
- “user is authenticated” being mistaken for “user is allowed”
How identity is conveyed (and verified)
“Treat headers as untrusted” is true, but incomplete. You need a concrete story for how identity crosses the boundary.
One pattern I like:
- The guard terminates TLS and validates OIDC tokens.
- The guard forwards identity to the app as a verifiable artifact (a guard-issued JWT with
aud/issset for the app, or an mTLS/workload identity in a mesh). - The app verifies that artifact (signature +
aud+iss+ expiry) and rejects anything it can’t verify.
The key detail: the guard–app hop is its own trust boundary. If the app can be reached by anything other than the guard, your “no header identity fallback” contract will get violated in practice (eventually).
In one picture (where requests get rejected, and what crosses the boundary):
Minimum contract between guard and app
Keep the hand-off boring and explicit:
- Every request that reaches the app carries a verifiable identity artifact.
- That artifact is app-scoped (
aud), issuer-pinned (iss), short-lived, and signed. - The app rejects requests without a valid artifact (no “header identity” fallback).
What “verifiable artifact” means in practice (minimum bar):
- Issuer separation: the guard-issued artifact must not be confused with the IdP token. Use a distinct
iss, distinct signing keys, and a distinct JWKS. - Key rotation: rotate with overlap (old+new keys valid), stable
kidhandling, and caching that won’t brick you during a rollout. During rotation, apps should accept both keys until the old one expires out of circulation. - Short TTL: keep expiry tight (seconds to a couple minutes) and define allowed clock skew explicitly.
- Audience discipline: set
audto the downstream app (or a narrow set) and reject broad/multi-audience tokens. - Replay assumptions: if you don’t use sender-constrained tokens (mTLS-bound / DPoP), assume replay within TTL is possible and design accordingly.
- Minimal contents: keep claims small and app-scoped (e.g.
sub, tenant, roles, maybe a session ID). Avoid forwarding full IdP tokens/claims downstream.
The guard → app hop is a trust boundary (enforce it)
Treat “only the guard can reach the app” as a hard requirement, not a diagram assumption:
- Block direct access with security groups / firewall rules / VPC routing and (in k8s)
NetworkPolicyor a service mesh inbound policy. - Prefer mTLS on the hop so the app can authenticate the caller as “the guard” (or at least a specific workload identity), not just “something inside the network”.
- Enforce this at the network layer and at the workload identity layer (mesh mTLS / service identity) where you can.
- If you can’t enforce this, don’t rely on header stripping as your safety story. Someone (or something) will eventually hit the app directly.
The anti-pattern:
- The guard injects
X-User: alice@example.comand the app trusts it because “only the proxy can reach it”.
That’s how you end up with accidental bypasses: a forgotten port, an internal load balancer, a request smuggling edge case, or a second proxy layer that forwards slightly different headers.
Do header sanitation at every boundary you operate (edge, ingress, mesh) so a second proxy doesn’t quietly reintroduce “trusted” headers. And in the app: ignore identity headers entirely, even if you believe they were stripped upstream.
If you’re doing path-based authz in the guard, make it explicit.
One important caveat: in Istio, the existence of AuthorizationPolicy resources can change the effective default for a workload. A snippet that looks like “protect only the sensitive paths” can turn into “deny everything else” if you already have ALLOW-style policies in play (or if you add one later).
Example surprise: you add an ALLOW policy that only matches /healthz (or only matches one JWT principal) for a workload. From that point on, everything else that doesn’t match an ALLOW can be denied unless you also add an explicit baseline allow rule.
Before you copy/paste:
DENYis evaluated beforeALLOW. Once you introduceALLOWpolicies for a workload, traffic typically becomes “default deny unless allowed” for the selected scope—so missing allow rules can suddenly block things you didn’t intend.- Test in a namespace with the same existing policies you run in prod. This is where “worked in dev” goes to die.
- This assumes your IdP issues stable group claims in the token. In practice, group claims can be huge, omitted, delivered out-of-band, or lag behind reality. Prefer mapping upstream groups → guard-minted roles and authorizing on roles instead of forwarding raw group lists.
- If you need near-real-time entitlement changes/revocation, don’t rely purely on self-contained JWT group claims: use short TTL + reauth, token exchange, or continuous authorization.
- It also assumes
RequestAuthentication(or your equivalent JWT validation layer) is set up for the workload. - Huge group claims can blow up token size and hit header/cookie limits, which tends to show up as “random” 431/502 errors through proxies.
# Example: deny sensitive paths for non-admins (coarse on purpose).
# Notes:
# - Prefer a DENY rule for “only block these paths” so the intent is obvious.
# - If you already use ALLOW policies for this workload, you still need an explicit allow rule for normal traffic.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: legacy-tool-deny-sensitive
spec:
selector:
matchLabels:
app: legacy-tool
action: DENY
rules:
- to:
- operation:
paths: ["/admin*", "/debug*", "/internal*"]
when:
- key: request.auth.claims[groups]
notValues: ["tool-admins"]
If this workload already has ALLOW policies (now or later), you typically also want a baseline allow rule for “normal authenticated traffic”, and then tighten it over time:
# Example: allow authenticated traffic (refine later).
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: legacy-tool-allow-authenticated
spec:
selector:
matchLabels:
app: legacy-tool
action: ALLOW
rules:
- from:
- source:
requestPrincipals: ["*"]
Path-policy foot-guns
Path-based authz works well, but it’s also where bypasses show up when layers disagree about what a path “means”.
- Normalize once (ideally at the proxy) and reject ambiguous forms (don’t just “normalize and hope”):
- reject invalid percent-encoding / invalid UTF-8
- normalize percent-encoding once (don’t decode twice)
- collapse dot-segments (
./..) - reject encoded slashes/backslashes if the proxy/app disagree about what they mean
- Prefer the app to use the proxy’s normalized path if your framework exposes both raw and decoded variants.
- Be careful with prefix matching for sensitive endpoints; prefer exact matches where you can.
- Test bypass shapes: double slashes (
//), encoded slashes (%2f), encoded dots, and weird..segments. - Treat
X-Forwarded-*/Forwardedconsistently so the app and proxy don’t disagree about scheme/host/path (and call outX-Forwarded-Hostexplicitly if your app ever builds absolute URLs).
Reject ambiguous HTTP (smuggling and parsing mismatches)
Path normalization is not the only perimeter foot-gun. A lot of real incidents are parsing mismatches between layers.
Minimum bar for the guard/proxy:
- Reject multiple
Content-Length. - Reject
Transfer-Encoding+Content-Lengthtogether (and other ambiguous length semantics). - Reject invalid header whitespace / obs-fold (header folding) and other parsing quirks instead of trying to “fix up” requests.
- Enforce a single
Hostand allowlist expected hosts. - Reject or deterministically handle duplicate security-relevant headers (
Host,Authorization,Forwarded/X-Forwarded-*). Prefer reject. - If you terminate HTTP/2 and forward HTTP/1, ensure the translation layer rejects ambiguous
:path/ request-target forms instead of producing different interpretations downstream.
If you run multiple proxy layers (edge LB → guard → sidecar), align parsing/normalization across them or make the outermost layer strict enough that inner layers never see ambiguous inputs.
Concrete test plan (quick)
- Unauthenticated request →
401/403(no redirect loops for APIs). - Bypass attempt: try to reach the app directly (bypass the guard) → blocked at the network layer.
- Header spoofing: send
X-User,X-Email,X-Auth-Request-*→ ignored; identity still comes only from the artifact. - Audience confusion: present a valid token with the wrong
aud→ rejected. - Path confusion: try
/%2e%2e/,//admin,%2f/ encoded slashes, mixed encodings → rejected or normalized consistently. - Smuggling shapes: ambiguous
Content-Length/Transfer-Encoding→ rejected before the app sees it. - Degraded key fetch: unknown
kid/ JWK refresh failure → no “temporary allow”; behavior is explicit and logged. - Logs: guard emits principal + decision + reason; app emits resource-level decision.
Shapes that work in practice
Pick one layer where you can enforce this consistently:
- At the edge: Cloud IAP / Cloudflare Access / Lambda@Edge / an API gateway. Great when you want “one choke point” before traffic ever hits your network.
- At the ingress: NGINX/Envoy + an auth sidecar. Straightforward, easy to roll out per service.
- In the mesh: Istio
RequestAuthentication+AuthorizationPolicy(or Envoyext_authz). Powerful when east/west matters and you already run a mesh.
None of these remove the need for app-level authz forever. They just buy you safety while teams keep shipping.
Rollout without drama
If I have the luxury to be a little bit of a scream-tester, my preferred rollout is “restrict first, then allow”:
- Authn for everything. No anonymous access “because internal”.
- Protect the sensitive paths first.
/admin,/debug,/internal, “download all data”, whatever your tool accidentally has. - Sane defaults, then exceptions. Default-deny for sensitive paths; default-allow for low-risk read paths if you must.
- Move in slices. Path-by-path or audience-by-audience, with clear rollback.
- Treat headers as untrusted. The app should never blindly accept “user” headers from anywhere except the guard.
One concrete rollout pattern that tends to stay boring:
- Put Authn in front of everything first (OIDC), with conservative timeouts and good logs.
- Add a DENY policy for the obviously sensitive paths (
/admin,/debug, exports) for non-admins. - Let the app ship for a bit while you fix the worst internal authz issues behind the perimeter (IDORs, “trust header” bugs, unsafe admin endpoints).
- Only then start tightening ALLOW rules if you actually need them.
Rollback should be equally boring: remove the new policy and redeploy (or flip a traffic route back to the old ingress) without having to touch the app.
In the real world, rollouts often start from a worse place: a tool already has users, the fastest win is “at least everyone has to log in”, and authorization gets layered in gradually.
That rollout looks more like:
- Ship Authn first. Put OIDC in front of the whole app.
- Start permissive, but log everything. Audit logs are your map of who uses what.
- Add guardrails where it matters. Tighten authz for sensitive paths early, even if the rest stays broad for a while.
- Ratchet with evidence. Use the logs to carve policy safely instead of breaking teams by accident.
Operational reality (failures, caching, break-glass)
Guards fail. Identity providers fail. Clock skew happens. Group lookups time out.
Things I try to make boring up front:
- Prefer local JWT validation over always-online introspection. Cache and refresh JWKs, and plan for rotation. When you need revocation guarantees (opaque tokens, high-risk apps), use introspection/token exchange or short TTL + continuous authorization.
- Cache authz inputs/decisions carefully (short TTL, keyed by principal + policy version) to avoid turning your directory/policy service into an outage amplifier.
- If JWK refresh fails, keep using cached keys until you hit your defined staleness limit; once you can’t validate, fail closed and log the reason.
- Keep timeouts and retries conservative. Don’t turn an IdP hiccup into a thundering herd.
- Decide what happens when authn is partially degraded: fail closed for sensitive paths, and don’t invent “temporary allow” modes without audit.
- Have an audited break-glass path for incident response. If the guard becomes the single outage lever, you will eventually need a way to get admins in without turning “disable auth” into the runbook. Make it MFA-gated, time-limited, approval-based, and send logs somewhere tamper-resistant.
Browsers and API clients are different
A “guard” can front both, but the mechanics differ:
- Browser flows often rely on cookies and redirects, which pulls in CSRF, callback URLs, and
SameSitebehavior. - API clients and service-to-service usually want bearer tokens (or mTLS identities) with predictable failure modes and no redirect gymnastics.
If you treat those as the same thing, you’ll end up with either broken automation or insecure browser sessions.
Browser guardrails (minimum bar)
If your guard fronts browser sessions, treat this as a separate checklist from “API bearer tokens”:
- Cookies: set
Secure+HttpOnly, scopeDomain/Pathintentionally, and rotate session IDs on login (session fixation defense). SameSite: pick a strategy intentionally (and document what breaks). If you useSameSite=None, you must also useSecure.- CSRF: choose an explicit approach (same-origin + CSRF tokens, double-submit, or a framework-native mechanism) and test it on state-changing routes.
- OIDC redirects: validate
state/nonce, defend callback replay, and store transient auth state server-side (or in a tamper-proof cookie) with tight expiry. - Redirects/callbacks: allowlist
redirect_uri/return_tostyle parameters and block open redirects; don’t build callback URLs from untrustedX-Forwarded-Host.
Why it’s worth doing even when you already have SSO
Because “SSO at the app” can still leave you with:
- weird auth bypasses (especially around admin paths)
- inconsistent session handling across services
- no reliable audit trail
- one-off auth implementations you don’t want to maintain
The boring bypasses you actually see
The “weird auth bypass” story is usually not clever attacker math. It’s “oops, we forgot an endpoint”.
This is especially common when auth is implemented route-by-route inside the app with manual decorators or guards (@require_login, @guard, etc.). Someone adds a new handler, forgets the decorator, and now you have an unprotected API.
Putting an OAuth proxy / edge guard / ingress policy in front of the app makes that class of mistake harder to ship. You still need app-level authz long term, but you don’t have to bet the farm on every endpoint being perfectly annotated from day one.
How you know it’s working
If you can’t measure it, you’re just adding a new moving part.
Signals I like:
- The “top blocked requests” dashboard is boring and stable (and not full of surprises).
- Sensitive endpoints have explicit coverage (“these paths are protected by policy X”).
- Incident response gets faster because the logs answer “who did what, from where, and why was it allowed?”
- Authz ratcheting slows down over time (fewer policy changes because the surface area is understood).
Guardrails shouldn’t feel like a speed bump. Done right, they’re a small, auditable control plane that lets teams ship while you bring the app up to standard.