Every endpoint you register on a resource can carry a policy that edge evaluates on each request: a boolean allow rule, inject rules that rewrite the forwarded request, and step-up requirements. The rules are written in CEL. This page lists the fields, the data a rule can read, the order of evaluation and what happens when something fails. You need a registered resource first (How to register a resource).
Policy fields
| Field | Type | Meaning |
|---|---|---|
allow | CEL, boolean | Must evaluate to true for the call to go on. Absent or blank: no check |
inject | list of rules | Each rule: target (header, query or body), name, expression (CEL). The result replaces the client’s value of that name |
fetchUserInfo | boolean | Load the user’s claims from auth /userinfo before evaluating rules. Costs one extra call per request |
stepUpCondition | CEL, boolean | When true, the token must carry an acr listed in stepUpAcr |
stepUpAcr | string | Space-separated acr values. The token’s acr must equal one of them |
maxAge | integer, seconds | The token’s auth_time must be no older than this. Checked on its own, with or without a condition |
Permissions are not part of the policy. A role or client permission must also grant the endpoint, and that check runs first (Permission).
What a rule can read
Every expression, allow, inject and stepUpCondition alike, sees the same three variables:
| Variable | Type | Content |
|---|---|---|
token | map | The claims of the validated access token. Whole numbers are CEL int, other numbers double. Always present: sub, client_id, aud (list), tenant_id, roles (list), iat, exp, jti. Present when auth issued them: acr, auth_time, sid, cnf |
user | map | The /userinfo claims. Empty unless fetchUserInfo is on |
request.path.params | map of string | Values bound by the {name} segments of the endpoint path |
request.query | map of string | First value of each query parameter |
request.queryAll | map of list of string | All values of each query parameter |
request.headers | map of string | Request headers, first value |
request.headersAll | map of list of string | Request headers, all values |
request.body | map | The parsed JSON body. Present only when Content-Type is application/json and the body is not empty |
The language is standard CEL with its standard macros: comparison, logical operators, in, arithmetic, ?:, has(x.y), all, exists, exists_one, map, filter, string and list functions. Expressions are compiled once and cached. The console validates syntax and the result type when you save, so a syntax error or a non-boolean allow blocks the save.
A rule that reads something the request does not carry is not an error in CEL, it is missing data. request.body.total on a request without a JSON body, or user.plan on a user without that claim, is missing data. Guard optional values with has():
!has(request.query.draft) || "manager" in token.roles
Missing data in allow means 403. Use has() wherever the value is optional.
Order of evaluation
flowchart TD
A["Request to /resources/{resourceId}/{path}"] --> B{"Token found?\nBearer, DPoP or EDGE_SESSION"}
B -- no --> R401A["401"]
B -- yes --> C{"Signature valid\nand not expired?"}
C -- "expired, cookie" --> C2["Refresh with stored refresh token\nrotate cookie"]
C -- "expired, header" --> R401B["401"]
C -- "invalid" --> R401C["401"]
C2 -- "refresh fails" --> R401D["401 + Location /login/{presetId}\ncookie cleared"]
C2 --> D
C -- yes --> D{"Revoked?\nin-memory list"}
D -- yes --> R401E["401"]
D -- no --> E{"Token binding OK?\nDPoP proof, cnf rules"}
E -- no --> R401F["401 + WWW-Authenticate"]
E -- yes --> F{"Resource and endpoint\nregistered?"}
F -- no --> R404["404"]
F -- yes --> G{"Role or client permission\ngrants this endpoint?"}
G -- no --> R403A["403"]
G -- yes --> H{"Audience covers\nthe resource?"}
H -- no --> R403B["403"]
H -- yes --> I["Fetch /userinfo\nonly if fetchUserInfo"]
I --> J{"allow rule true?"}
J -- "false or data missing" --> R403C["403"]
J -- "rule broken" --> R500A["500"]
J -- yes --> K{"Step-up needed?\nstepUpCondition, stepUpAcr, maxAge"}
K -- yes --> R401G["401 + WWW-Authenticate\ninsufficient_user_authentication"]
K -- "condition broken" --> R500B["500"]
K -- no --> L["Evaluate inject rules\nheader, query, body"]
L -- "data missing" --> R403D["403"]
L -- "rule broken" --> R500C["500"]
L --> M["Forward to the resource\nBasic credential or caller's Bearer token"]
- Token. Found, signature valid, not expired. An expired cookie token is refreshed, an expired header token is not.
- Revocation. Looked up in memory by token ID, refresh-token family, session and subject.
- Binding. DPoP proof,
cnfrules (Sender-constrained tokens). - Lookup. The resource, then the endpoint by method and path. A literal segment beats a
{param}segment, so/users/mewins over/users/{userId}. - Permission. A role of the caller grants the endpoint, or, for a service token (
subequalsclient_id), the client’s permissions do. - Audience.
audholdsresource://{resourceId}(orresource://edge) for an internal resource, or the resource’s absolute URI for a public one. - Userinfo, only when
fetchUserInfois on. allow.- Step-up. After
allowon purpose: a caller with no right to the endpoint gets a plain 403 and never a re-authentication prompt that ends in the same denial. - Inject, then forward.
Rules run on the data of this one request. Nothing is shared between requests, and allow and stepUpCondition cannot read each other’s result.
Examples
Allow by role or claim
GET /invoices with an optional draft filter. Drafts are for managers; everyone else with the permission sees finalised invoices.
| Field | Value |
|---|---|
| Method, path | GET /invoices |
allow | !has(request.query.draft) || "manager" in token.roles |
A claim that only userinfo carries needs fetchUserInfo on:
| Field | Value |
|---|---|
| Method, path | GET /reports |
| Fetch userinfo | on |
allow | has(user.department) && user.department == "finance" |
Inject the user’s identity
The upstream reads identity from headers and never parses a token.
| Target | Name | Expression |
|---|---|---|
header | X-User-Id | token.sub |
header | X-Tenant-Id | token.tenant_id |
Whatever the client sent as X-User-Id is removed first, so the header cannot be spoofed. The same works for a query rule (name tenant, expression token.tenant_id) or a body rule (name userId, expression token.sub).
Path-parameter ownership
Users may read only their own orders.
| Field | Value |
|---|---|
| Method, path | GET /users/{userId}/orders |
allow | request.path.params.userId == token.sub |
Step-up for a sensitive endpoint
Transfers above a limit need a stronger authentication, and every transfer needs a login no older than ten minutes.
| Field | Value |
|---|---|
| Method, path | POST /payments/transfer |
allow | has(request.body.amount) && request.body.amount > 0 |
stepUpCondition | request.body.amount > 100000 |
stepUpAcr | otp |
maxAge | 600 |
stepUpAcr takes values from your tenant’s ACR vocabulary (Challenges & Security in the console). A request that fails either check gets:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="otp", max_age="600"
The challenge lists every unmet requirement, so one re-authentication fixes both. Your app sends the browser to /login/{presetId}?acr_values=otp&max_age=600 and retries after sign-in (RFC 9470).
To demand an acr on every call, set stepUpCondition to true. With a stepUpAcr and no condition, nothing is required. With a condition that is true and no stepUpAcr, only maxAge applies.
Failure semantics
| Situation | Response | Logged error code |
|---|---|---|
| No credential | 401 | token_missing |
| Bad signature, expired header token | 401 | token_invalid, token_expired |
| Expired cookie token that cannot be refreshed | 401 with Location: /login/{presetId} and the cookie cleared | none |
| Revoked token | 401 | none |
| Binding failed | 401, WWW-Authenticate: DPoP error="invalid_dpop_proof" or use_dpop_nonce with DPoP-Nonce, or a bare 401 | dpop_*, certificate_bound_token_outside_session |
| Unknown resource, or no endpoint for this method and path | 404 | none |
| No permission for the endpoint | 403 | permission_denied |
| Audience does not cover the resource | 403 | audience_denied |
auth rejects the token at /userinfo | 401 | user_info_unauthorized |
/userinfo fails otherwise | 500 | none |
allow is false | 403 | access_rule_denied |
allow reads missing data | 403 | access_rule_denied, with a description |
allow is broken: does not compile, runtime error, not a boolean | 500 | access_rule_failed |
| Step-up unmet | 401, WWW-Authenticate: Bearer error="insufficient_user_authentication" | step_up_required |
stepUpCondition reads missing data | treated as true | none |
stepUpCondition is broken | 500 | step_up_condition_failed |
inject reads missing data | 403 | inject_rule_denied |
inject is broken | 500 | inject_rule_failed |
Denials carry the bare status with no explanation; the code appears under the error key of the request’s log line and in the metrics. An inject rule that evaluates to null adds nothing, and the client’s value of that name is still removed.
Two limits to plan around. Inject values are strings: a number or boolean result is converted with toString. Body injection needs a JSON object body, and any other body is forwarded untouched without injection.
Set a policy in the console
- Open Resources, press + Create Resource, or open a resource and edit it.
- Press Add endpoint. Enter the method and the relative path, for example
/users/{userId}/orders. - Turn on Fetch userinfo if a rule reads
user. - Enter Allow (CEL). Leave it empty for no check.
- Under Inject, press Add rule and set Target, Name and Expression (CEL).
- Enter Step-up Condition (CEL), Step-up ACR and Max Auth Age (seconds).
- Save the resource.
edgepicks the change up withinconfiguration-cache-refresh-interval.


Saving the resource does not grant anyone access. Attach the endpoint to a permission, and the permission to a role, as in How to register a permission.
Next steps
- Protect an API with edge: a complete example with curl.
- Edge overview: the request lifecycle around the pipeline.
- RFC 9470: the step-up challenge.