·
One Proxy, Every Authorization Decision: Inside Versola's Edge
How Versola's edge proxy turns permissions, CEL-based dynamic rules, RFC 9470 step-up and identity injection into one ordered pipeline in front of every service, with a single observability layer covering all of it.
Open three services in a typical backend and you’ll usually find three copies of the same paragraph: extract the token, check a role, check a scope, maybe check that the caller owns the resource they’re touching, then let the request through. They started as one paragraph, copy-pasted. A year later one of them checks body.total against a spend limit and the other two don’t, because whoever wrote the third endpoint didn’t know the rule existed.
That drift isn’t a discipline problem. It’s what happens when the same decision is implemented N times. Versola’s edge exists to make it a decision implemented once: every request to every internal resource passes through it, and by the time it reaches your service, permissions, business rules, step-up, and identity have already been resolved.
This post walks through the whole pipeline, in the order edge actually runs it.
The pipeline
Seven checks run in a fixed order for every proxied request. Each one can end the request; nothing after it runs.
flowchart TD
A["Request arrives\nBearer token or session cookie"] --> B{"Token valid\nand unexpired?"}
B -- "no, cookie session" --> B2["Refresh via stored\nrefresh token"]
B -- "no, bearer header" --> R401A["401"]
B2 --> C
B -- yes --> C{"Revoked?\nConcurrentHashMap.get"}
C -- yes --> R401B["401"]
C -- no --> D{"Permission grants\nthis endpoint?"}
D -- no --> R403A["403"]
D -- yes --> E{"Audience matches\nresource?"}
E -- no --> R403B["403"]
E -- yes --> F["Fetch /userinfo\n(only if endpoint asks)"]
F --> G{"CEL allow rule\ntrue?"}
G -- no --> R403C["403"]
G -- yes --> H{"Step-up condition met\nand ACR/auth_time satisfy it?"}
H -- no --> R401C["401 + WWW-Authenticate"]
H -- yes --> I["Apply inject rules\nheader / query / body"]
I --> J["Proxy to upstream\nwith edge's own credential or caller's token"]
- Token validity. Signature, expiry, issuer. An expired cookie session gets one refresh attempt against the stored refresh token before failing; an expired bearer header does not, because there’s no session to refresh.
- Revocation. A
ConcurrentHashMaplookup keyed by token id, session id, and subject, kept current by PostgresLISTEN/NOTIFY. No query on the hot path. Covered in full in why you can’t revoke a JWT, and the one setup where you can. - Permissions. Coarse RBAC: does any role (or, for a service token, any client permission) grant this specific endpoint.
- Audience. Was this token issued for this resource.
- CEL allow rule. A per-endpoint boolean expression for anything RBAC can’t express.
- Step-up (RFC 9470). Does this request need a stronger or fresher authentication than the token carries.
- Inject. Rewrite headers, query params, or body fields before forwarding, most commonly to stamp the caller’s identity onto the upstream request.
Steps 3 through 6 are configured per endpoint, in central, by whoever owns the resource. None of it is code in the service being protected.
Layer 1: permissions are the coarse gate
Permissions answer one question: is this role, or this client, allowed to call this endpoint at all. They’re resolved from an in-memory cache built from three maps — role → permission, permission → endpoint, client → permission — refreshed on an interval, not pushed:
override def getAllowedEndpointsForRoles(tenantId: TenantId, roles: List[RoleId]): UIO[Set[ResourceEndpointId]] =
for
roleMap <- rolesCache.get
permMap <- permissionsCache.get
permIds = permissionsFor(tenantId, roles, roleMap)
yield permIds.flatMap(permMap.getOrElse(_, Set.empty))
That’s deliberate: permission structure changes rarely enough that a bounded staleness window (governed by configurationCacheRefreshInterval) is a better trade than a subscription per replica for data that moves once a day. Revocation, which has to be immediate because a compromised session can’t wait for a poll interval, gets LISTEN/NOTIFY instead. Not every kind of state deserves the same freshness guarantee, and treating them differently is the point, not an inconsistency.
A denied permission is a 403 before a token has been checked against anything endpoint-specific. This is intentionally the cheapest and least informative failure: it tells the caller nothing about what the endpoint actually requires.
Layer 2: CEL for the rule RBAC can’t express
A role answers “can this user hit POST /orders”. It can’t answer “is this specific order theirs” or “is this specific amount within their limit,” because those depend on the request, not the role. That’s what the allow field is for — a CEL expression evaluated per request against three roots:
| Root | Contents |
|---|---|
token | claims of the validated access token |
user | /userinfo claims, only fetched when the endpoint asks for them |
request | path.params, query, headers, and body when the request is JSON |

The expanded endpoint in that screenshot reads request.body.total <= 50000 && user.subscription == 'premium', on top of the orders:write permission already gating the endpoint. Permissions decide who’s in the room; CEL decides what they’re allowed to do once they’re there.
Programs are compiled ahead of time, when the rule cache loads, not on the first request that reaches them, so no request ever pays a compilation cost.
Layer 3: step-up, when the rule isn’t about permission but about the strength and freshness of authentication
Some decisions aren’t “can this caller do this” but “has this caller proven who they are recently enough to do this.” A transfer under a thousand goes through on a password session; above it, RFC 9470 says the resource should be able to demand a stronger authentication before honoring the request — and be explicit about what “stronger” means.
Three more fields on the same endpoint handle it: stepUpCondition, stepUpAcr, and maxAge:

{
"id": 501,
"method": "POST",
"path": "/payments/transfer",
"allow": "request.body.payment <= user.dailyTransferLimit",
"inject": [{ "target": "body", "name": "userId", "expression": "token.sub" }],
"stepUpCondition": "request.body.payment > 100000",
"stepUpAcr": "otp",
"maxAge": 600
}
stepUpCondition is CEL over the same context as allow. When it evaluates to true, stepUpAcr becomes mandatory; maxAge is checked independently, so an endpoint can demand a maximum session age without a conditional ACR at all. A request that fails either gets one 401 carrying both:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="otp", max_age="600"
The check runs after permissions, audience, and the allow rule on purpose: a caller with no right to hit this endpoint at all gets a definitive 403, not a step-up prompt that would lead them through a fresh OTP only to land on the same denial. Once the client redirects through /authorize with the requested acr_values, auth resolves it against what the user actually has registered — a passkey, a phone for OTP — and fails the authorization request outright with unmet_authentication_requirements if it isn’t achievable, rather than let the client loop between a challenge it can never satisfy and a login page.
Layer 4: injection, so the upstream service doesn’t parse the token itself
Every allow and stepUpCondition expression sees the same CEL context, and inject reuses it a third time to rewrite the outgoing request:
val grouped = endpoint.inject.groupBy(_.target)
val headerInjects = grouped.getOrElse(InjectTarget.header, Vector.empty)
val queryInjects = grouped.getOrElse(InjectTarget.query, Vector.empty)
val bodyInjects = grouped.getOrElse(InjectTarget.body, Vector.empty)
The screenshots above both inject userId from token.sub into the request body. The upstream service never sees the caller’s token, never verifies a signature, and never has an opinion about JWKS rotation — it reads userId off the body like any other field, because edge overwrites whatever the client sent there before forwarding. Injected values always win over client-supplied ones; that ordering is what makes injection safe to use for identity rather than just convenience.
Internal resources go a step further: edge swaps the caller’s token for its own Authorization: Basic credential to that resource, so a compromised upstream service that logs its inbound headers never captures a user’s access token at all.
Observability: the fifth thing every layer shares
None of the first four layers would be worth centralizing if failures in them were invisible. Every request gets one span, one log line, and a shared set of metrics, regardless of which layer stopped it.
Routes are labeled by template, not by path. /resources/orders-api/orders/{id} is one metric series and one span name no matter how many distinct order ids get requested — path parameters can’t blow up cardinality, because the instrumentation renders the registered pattern, not the matched request.
Every layer reports through the same vocabulary. checkPermissions sets permission_denied, checkAudience sets audience_denied, checkRules sets access_rule_denied or access_rule_failed, checkStepUp sets step_up_required or step_up_condition_failed. All of it lands under one error key on the request’s log line:
def setError(code: String, description: Option[String] = None): UIO[Unit] =
logContext.update(_.annotate(error, ErrorDetails(code, description)))
A closed, stable vocabulary is what makes “alert when access_rule_denied spikes for resource X” a query instead of a grep through free text. code stays closed for exactly that reason; description is where a check adds free text when it has something worth saying — which is how a denial caused by a value missing from the request stays distinguishable from a rule that evaluated cleanly to false without needing a code of its own.
Identity attaches mid-flow and survives to the final log line. As soon as a token is validated, edge annotates the log context with the token id, subject, and session id. Every subsequent log line in that request — including the one line written when the response goes out — carries them, without any layer having to thread them through explicitly:
private def logAccessTokenClaims(claims: AccessTokenClaims): UIO[Unit] =
Observability.setToken(claims.jti) *>
Observability.setUserId(claims.subject) *>
ZIO.foreachDiscard(claims.sid)(Observability.setSessionId)
Traces cross the proxy boundary instead of stopping at it. The incoming request’s trace context is extracted and used to open a server span; the outgoing request to the upstream service opens a client span under the same trace. A denial at step 5 and a slow response from the actual resource show up in the same trace, so “why did this request take 800ms” doesn’t require correlating logs across two services by hand.
Because every one of the seven checks runs inside this same middleware, none of it is opt-in per service. A resource that adds a stepUpCondition next week gets step_up_required in its metrics next week, automatically, the same way it already gets access_rule_denied for its existing allow rule.
What doesn’t fit in one hop
The proxy only sees what’s in the request. A JSON body, a whitelisted set of headers, query parameters, path segments. A decision that needs the caller’s transaction history, or a fraud score computed inside the service from data edge never touches, can’t become a CEL expression — the service still owns that check and returns its own denial.
Permission changes lag by the cache refresh interval. Revoking a token is instant because it’s pushed. Revoking a permission — removing orders:write from a role — takes up to one refresh interval to reach every edge replica. That’s a reasonable trade for how rarely role structure changes, but it is a real trade, and it’s the wrong one for anything that needs to be denied immediately.
A fail-closed CEL evaluator can also fail closed on a typo. An expression that references a field absent from this particular request evaluates to false, not an error, so the caller still gets a plain 403. The cel key makes that case identifiable after the fact rather than silent, but nothing turns it into an alert on its own, and config-time validation can’t catch it up front: it checks that the expression parses and returns a boolean, not that “this field is sometimes absent.”
Injection overwrites; it doesn’t merge. A body inject targeting a field the client also sent replaces it entirely. That’s the correct behavior for identity (userId must never be attacker-controlled) and the wrong shape for anything that should be additive.
One proxy is also one blast radius. Centralizing the decision means a bug in edge affects every resource behind it, not just one service. The seven-step pipeline being fixed and shared is what makes it auditable in one place — it’s also what makes a regression here more expensive than a regression in one service’s middleware.
Common questions
Why not just put all of this in an OPA sidecar per service? You can, and it solves the “N copies” problem the same way. What it doesn’t give you for free is the request already being on a proxy’s hot path with the token validated and revocation checked — you’d still need something upstream of the sidecar doing that, or every sidecar re-verifies the token itself.
Does the CEL context ever see the raw client secret or refresh token? No. token is the deserialized access token claims, never a raw credential; edge holds the resource’s own outbound secret separately and never exposes it to an expression.
Can allow and stepUpCondition reference each other’s outcome? No, they’re independent checks over the same context, evaluated in a fixed order (allow before stepUp). A step-up condition can reference anything allow can, but not the boolean result of allow itself.
What happens to metrics cardinality if I add a lot of endpoints? Each registered endpoint’s template is a distinct route label, so metrics scale with the number of registered endpoints, not the number of distinct requests. A hundred endpoints is a hundred series; a million requests to one endpoint is still one.