docs/enhancements/cel-expressions-2026-02-28.md
This DEP proposes integrating CEL (Common Expression Language) into Dex as a first-class
expression engine for policy evaluation, claim mapping, and token customization. A new reusable
pkg/cel package will provide a safe, sandboxed CEL environment with Kubernetes-grade compatibility
guarantees, cost budgets, and a curated set of extension libraries. Subsequent phases will leverage
this package to implement authentication policies, token policies, advanced claim mapping in
connectors, and per-client/global access rules — replacing the need for ad-hoc configuration fields
and external policy engines.
ClaimMapping, ClaimMutations.NewGroupFromClaims, FilterGroupClaims, ModifyGroupNames)
that would benefit from a unified expression language.Complex query/filter capabilities — Dex needs a way to express complex validations and mutations in multiple places (authentication flow, token issuance, claim mapping). Today each feature requires new Go code, new config fields, and a new release cycle. CEL allows operators to express these rules declaratively without code changes.
Authentication policies — Operators want to control who can log in based on rich conditions: restrict specific connectors to specific clients, require group membership for certain clients, deny login based on email domain, enforce MFA claims, etc. Currently there is no unified mechanism; users rely on downstream applications or external proxies.
Token policies — Operators want to customize issued tokens: add extra claims to ID tokens,
restrict scopes per client, modify aud claims, include upstream connector metadata, etc.
Today this requires forking Dex or using a reverse proxy.
Claim mapping in OIDC connector — The OIDC connector has accumulated multiple ad-hoc config
options for claim mapping and group mutations (ClaimMapping, NewGroupFromClaims,
FilterGroupClaims, ModifyGroupNames). A single CEL expression field would replace all of
these with a more powerful and composable approach.
Per-client and global policies — One of the most frequent requests is allowing different connectors for different clients and restricting group-based access per client. CEL policies at the global and per-client level address this cleanly.
CNCF ecosystem alignment — CEL has massive adoption across the CNCF ecosystem:
| Project | CEL Usage | Evidence |
|---|---|---|
| Kubernetes | ValidatingAdmissionPolicy, CRD validation rules (x-kubernetes-validations), AuthorizationPolicy, field selectors, CEL-based match conditions in webhooks | KEP-3488, CRD Validation Rules, AuthorizationPolicy KEP-3221 |
| Kyverno | CEL expressions in validation/mutation policies (v1.12+), preconditions | Kyverno CEL docs |
| OPA Gatekeeper | Partially added support for CEL in constraint templates | Gatekeeper CEL |
| Istio | AuthorizationPolicy conditions, request routing, telemetry | Istio CEL docs |
| Envoy / Envoy Gateway | RBAC filter, ext_authz, rate limiting, route matching, access logging | Envoy CEL docs |
| Tekton | Pipeline when expressions, CEL custom tasks | Tekton CEL Interceptor |
| Knative | Trigger filters using CEL expressions | Knative CEL filters |
| Google Cloud | IAM Conditions, Cloud Deploy, Security Command Center | Google IAM CEL |
| Cert-Manager | CertificateRequestPolicy approval using CEL | cert-manager approver-policy CEL |
| Cilium | Hubble CEL filter logic | Cilium CEL docs |
| Crossplane | Composition functions with CEL-based patch transforms | Crossplane CEL transforms |
| Kube-OVN | Network policy extensions using CEL | Kube-OVN CEL |
By choosing CEL, Dex operators who already use Kubernetes or other CNCF tools can reuse their existing knowledge of the expression language.
ClaimMapping,
ClaimMutations, etc.) will continue to work. CEL expressions are additive/opt-in.pkg/cel package) is targeted for
immediate implementation. Phases 2-4 are included here for design context and will have their
own implementation PRs.Operators can define global and per-client authentication policies in the Dex config:
# Global authentication policy — each expression evaluates to bool.
# If true — the request is denied. Evaluated in order; first match wins.
authPolicy:
- expression: "!identity.email.endsWith('@example.com')"
message: "'Login restricted to example.com domain'"
- expression: "!identity.email_verified"
message: "'Email must be verified'"
staticClients:
- id: admin-app
name: Admin Application
secret: ...
redirectURIs: [...]
# Per-client policy — same structure as global
authPolicy:
- expression: "!(request.connector_id in ['okta', 'ldap'])"
message: "'This application requires Okta or LDAP login'"
- expression: "!('admin' in identity.groups)"
message: "'Admin group membership required'"
Operators can add extra claims or mutate token contents:
tokenPolicy:
# Global mutations applied to all ID tokens
claims:
# Add a custom claim based on group membership
- key: "'role'"
value: "identity.groups.exists(g, g == 'admin') ? 'admin' : 'user'"
# Include connector ID as a claim
- key: "'idp'"
value: "request.connector_id"
# Add department from upstream claims (only if present)
- key: "'department'"
value: "identity.extra['department']"
condition: "'department' in identity.extra"
staticClients:
- id: internal-api
name: Internal API
secret: ...
redirectURIs: [...]
tokenPolicy:
claims:
- key: "'custom-claim.company.com/team'"
value: "identity.extra['team'].orValue('engineering')"
# Only add on-call claim for ops group members
- key: "'on_call'"
value: "true"
condition: "identity.groups.exists(g, g == 'ops')"
# Restrict scopes
filter:
expression: "request.scopes.all(s, s in ['openid', 'email', 'profile'])"
message: "'Unsupported scope requested'"
Replace ad-hoc claim mapping with CEL:
connectors:
- type: oidc
id: corporate-idp
name: Corporate IdP
config:
issuer: https://idp.example.com
clientID: dex-client
clientSecret: ...
# CEL-based claim mapping — replaces claimMapping and claimModifications
claimMappingExpressions:
username: "claims.preferred_username.orValue(claims.email)"
email: "claims.email"
groups: >
claims.groups
.filter(g, g.startsWith('dex:'))
.map(g, g.trimPrefix('dex:'))
emailVerified: "claims.email_verified.orValue(true)"
# Extra claims to pass through to token policies
extra:
department: "claims.department.orValue('unknown')"
cost_center: "claims.cost_center.orValue('')"
pkg/cel — Core CEL LibraryThis is the foundation that all subsequent phases build upon. The package provides a safe, reusable CEL environment with Kubernetes-grade guarantees.
pkg/
cel/
cel.go # Core Environment, compilation, evaluation
types.go # CEL type declarations (Identity, Request, etc.)
cost.go # Cost estimation and budgeting
doc.go # Package documentation
library/
email.go # Email-related CEL functions
groups.go # Group-related CEL functions
github.com/google/cel-go v0.27.0
The cel-go library is the canonical Go implementation maintained by Google, used by Kubernetes
and all major CNCF projects. It follows semantic versioning and provides strong backward
compatibility guarantees.
Public types:
// CompilationResult holds a compiled CEL program ready for evaluation.
type CompilationResult struct {
Program cel.Program
OutputType *cel.Type
Expression string
}
// Compiler compiles CEL expressions against a specific environment.
type Compiler struct { /* ... */ }
// CompilerOption configures a Compiler.
type CompilerOption func(*compilerConfig)
Compilation pipeline:
Each Compile* call performs these steps sequentially:
MaxExpressionLength (10,240 chars).cel-go.defaultCostEstimator with size hints — reject if estimated max cost
exceeds the cost budget.cel.Program with runtime cost limit.Presence tests (has(field), 'key' in map) have zero cost, matching Kubernetes CEL behavior.
Variables are declared via VariableDeclaration{Name, Type} and registered with NewCompiler.
Helper constructors provide pre-defined variable sets:
IdentityVariables() — the identity variable (from connector.Identity),
typed as cel.ObjectType:
| Field | CEL Type | Source |
|---|---|---|
identity.user_id | string | connector.Identity.UserID |
identity.username | string | connector.Identity.Username |
identity.preferred_username | string | connector.Identity.PreferredUsername |
identity.email | string | connector.Identity.Email |
identity.email_verified | bool | connector.Identity.EmailVerified |
identity.groups | list(string) | connector.Identity.Groups |
RequestVariables() — the request variable (from RequestContext),
typed as cel.ObjectType:
| Field | CEL Type |
|---|---|
request.client_id | string |
request.connector_id | string |
request.scopes | list(string) |
request.redirect_uri | string |
ClaimsVariable() — the claims variable for raw upstream claims as map(string, dyn).
Typing strategy:
identity and request use cel.ObjectType with explicitly declared fields. This gives
compile-time type checking: a typo like identity.emial is rejected at config load time
rather than silently evaluating to null in production — critical for an auth system where a
misconfigured policy could lock users out.
claims remains map(string, dyn) because its shape is genuinely unknown — it carries
arbitrary upstream IdP data.
Following the Kubernetes CEL compatibility model (KEP-3488: CEL for Admission Control, Kubernetes CEL Migration Guide):
Environment versioning — The CEL environment is versioned. When new functions or variables are added, they are introduced under a new environment version. Existing expressions compiled against an older version continue to work.
// EnvironmentVersion represents the version of the CEL environment.
// New variables, functions, or libraries are introduced in new versions.
type EnvironmentVersion uint32
const (
// EnvironmentV1 is the initial CEL environment.
EnvironmentV1 EnvironmentVersion = 1
)
// WithVersion sets the target environment version for the compiler.
func WithVersion(v EnvironmentVersion) CompilerOption
This is directly modeled on k8s.io/apiserver/pkg/cel/environment.
Library stability — Custom functions in the pkg/cel/library subpackage follow these rules:
EnvironmentVersion.Type stability — CEL types (Identity, Request, Claims) follow the same rules:
EnvironmentVersion.Semantic versioning of cel-go — The cel-go dependency follows semver. Dex pins to a
minor version range and updates are tested for behavioral changes. This is exactly the approach
Kubernetes takes: k8s.io/apiextensions-apiserver pins cel-go and gates new features behind
environment versions.
Feature gates — New CEL-powered features are gated behind Dex feature flags (using the
existing pkg/featureflags mechanism) during their alpha phase.
Like Kubernetes, Dex CEL expressions must be bounded to prevent denial-of-service.
Constants:
| Constant | Value | Description |
|---|---|---|
DefaultCostBudget | 10_000_000 | Max cost units per evaluation (aligned with Kubernetes) |
MaxExpressionLength | 10_240 | Max expression string length in characters |
DefaultStringMaxLength | 256 | Estimated max string size for cost estimation |
DefaultListMaxLength | 100 | Estimated max list size for cost estimation |
How it works:
A defaultCostEstimator (implementing checker.CostEstimator) provides size hints for known
variables (identity, request, claims) so the cel-go cost estimator doesn't assume
unbounded sizes. It also provides call cost estimates for custom Dex functions
(dex.emailDomain, dex.emailLocalPart, dex.groupMatches, dex.groupFilter).
Expressions are validated at three levels:
MaxExpressionLength.The pkg/cel environment includes these cel-go standard extensions (same set as Kubernetes):
| Library | Description | Examples |
|---|---|---|
ext.Strings() | Extended string functions | "hello".upperAscii(), "foo:bar".split(':'), s.trim(), s.replace('a','b') |
ext.Encoders() | Base64 encoding/decoding | base64.encode(bytes), base64.decode(str) |
ext.Lists() | Extended list functions | list.slice(1, 3), list.flatten() |
ext.Sets() | Set operations on lists | sets.contains(a, b), sets.intersects(a, b), sets.equivalent(a, b) |
ext.Math() | Math functions | math.greatest(a, b), math.least(a, b) |
Plus custom Dex libraries in the pkg/cel/library subpackage, each implementing the
cel.Library interface:
library.Email — email-related helpers:
| Function | Signature | Description |
|---|---|---|
dex.emailDomain | (string) -> string | Returns the domain portion of an email address. dex.emailDomain("[email protected]") == "example.com" |
dex.emailLocalPart | (string) -> string | Returns the local part of an email address. dex.emailLocalPart("[email protected]") == "user" |
library.Groups — group-related helpers:
| Function | Signature | Description |
|---|---|---|
dex.groupMatches | (list(string), string) -> list(string) | Returns groups matching a glob pattern. dex.groupMatches(identity.groups, "team:*") |
dex.groupFilter | (list(string), list(string)) -> list(string) | Returns only groups present in the allowed list. dex.groupFilter(identity.groups, ["admin", "ops"]) |
// 1. Create a compiler with identity and request variables
compiler, _ := cel.NewCompiler(
append(cel.IdentityVariables(), cel.RequestVariables()...),
)
// 2. Compile a policy expression (type-checked, cost-estimated)
prog, _ := compiler.CompileBool(
`identity.email.endsWith('@example.com') && 'admin' in identity.groups`,
)
// 3. Evaluate against real data
result, _ := cel.EvalBool(ctx, prog, map[string]any{
"identity": cel.IdentityFromConnector(connectorIdentity),
"request": cel.RequestFromContext(cel.RequestContext{...}),
})
// result == true
Config Model:
// AuthPolicy is a list of deny expressions evaluated after a user
// authenticates with a connector. Each expression evaluates to bool.
// If true — the request is denied. Evaluated in order; first match wins.
type AuthPolicy []PolicyExpression
// PolicyExpression is a CEL expression with an optional human-readable message.
type PolicyExpression struct {
// Expression is a CEL expression that evaluates to bool.
Expression string `json:"expression"`
// Message is a CEL expression that evaluates to string (displayed to the user on deny).
// If empty, a generic message is shown.
Message string `json:"message,omitempty"`
}
Evaluation point: After connector.CallbackConnector.HandleCallback() or
connector.PasswordConnector.Login() returns an identity, and before the auth request is
finalized. Implemented in server/handlers.go at handleConnectorCallback.
Available CEL variables: identity (from connector), request (client_id, connector_id,
scopes, redirect_uri).
Compilation: All policy expressions are compiled once at config load time (in
cmd/dex/serve.go) and stored in the Server struct. This ensures:
Evaluation flow:
User authenticates via connector
│
v
connector.HandleCallback() returns Identity
│
v
Evaluate global authPolicy (in order)
- For each expression: evaluate → bool
- If true → deny with message, HTTP 403
│
v
Evaluate per-client authPolicy (in order)
- Same logic as global
│
v
Continue normal flow (approval screen or redirect)
Config Model:
// TokenPolicy defines policies for token issuance.
type TokenPolicy struct {
// Claims adds or overrides claims in the issued ID token.
Claims []ClaimExpression `json:"claims,omitempty"`
// Filter validates the token request. If expression evaluates to false,
// the request is denied.
Filter *PolicyExpression `json:"filter,omitempty"`
}
type ClaimExpression struct {
// Key is a CEL expression evaluating to string — the claim name.
Key string `json:"key"`
// Value is a CEL expression evaluating to dyn — the claim value.
Value string `json:"value"`
// Condition is an optional CEL expression evaluating to bool.
// When set, the claim is only included in the token if the condition
// evaluates to true. If omitted, the claim is always included.
Condition string `json:"condition,omitempty"`
}
Evaluation point: In server/oauth2.go during ID token construction, after standard
claims are built but before JWT signing.
Available CEL variables: identity, request, existing_claims (the standard claims already
computed as map(string, dyn)).
Claim merge order:
tokenPolicy.claims evaluated and mergedtokenPolicy.claims evaluated and merged (overrides global)Reserved (forbidden) claim names:
Certain claim names are reserved and MUST NOT be set or overridden by CEL token policy expressions. Attempting to use a reserved claim key will result in a config validation error at startup. This prevents operators from accidentally breaking the OIDC/OAuth2 contract or undermining Dex's security guarantees.
// ReservedClaimNames is the set of claim names that CEL token policy
// expressions are forbidden from setting. These are core OIDC/OAuth2 claims
// managed exclusively by Dex.
var ReservedClaimNames = map[string]struct{}{
"iss": {}, // Issuer — always set by Dex to its own issuer URL
"sub": {}, // Subject — derived from connector identity, must not be spoofed
"aud": {}, // Audience — determined by the OAuth2 client, not policy
"exp": {}, // Expiration — controlled by Dex token TTL configuration
"iat": {}, // Issued At — set by Dex at signing time
"nbf": {}, // Not Before — set by Dex at signing time
"jti": {}, // JWT ID — generated by Dex for token revocation/uniqueness
"auth_time": {}, // Authentication Time — set by Dex from the auth session
"nonce": {}, // Nonce — echoed from the client's authorization request
"at_hash": {}, // Access Token Hash — computed by Dex from the access token
"c_hash": {}, // Code Hash — computed by Dex from the authorization code
}
The reserved list is enforced in two places:
ClaimExpression entries, Dex statically
evaluates the Key expression (which must be a string literal or constant-foldable) and rejects
it if the result is in ReservedClaimNames.ReservedClaimNames and logs a warning + skips the claim if it matches. This
guards against dynamic key expressions that couldn't be statically checked.Config Model:
In connector/oidc/oidc.go:
type Config struct {
// ... existing fields ...
// ClaimMappingExpressions provides CEL-based claim mapping.
// When set, these take precedence over ClaimMapping and ClaimMutations.
ClaimMappingExpressions *ClaimMappingExpression `json:"claimMappingExpressions,omitempty"`
}
type ClaimMappingExpression struct {
// Username is a CEL expression evaluating to string.
// Available variable: 'claims' (map of upstream claims).
Username string `json:"username,omitempty"`
// Email is a CEL expression evaluating to string.
Email string `json:"email,omitempty"`
// Groups is a CEL expression evaluating to list(string).
Groups string `json:"groups,omitempty"`
// EmailVerified is a CEL expression evaluating to bool.
EmailVerified string `json:"emailVerified,omitempty"`
// Extra is a map of claim names to CEL expressions evaluating to dyn.
// These are carried through to token policies.
Extra map[string]string `json:"extra,omitempty"`
}
Available CEL variable: claims — map(string, dyn) containing all raw upstream claims from
the ID token and/or UserInfo endpoint.
This replaces the need for ClaimMapping, NewGroupFromClaims, FilterGroupClaims, and
ModifyGroupNames with a single, more powerful mechanism.
Backward compatibility: When claimMappingExpressions is nil, the existing ClaimMapping and
ClaimMutations logic is used unchanged. When claimMappingExpressions is set, a startup warning is
logged if legacy mapping fields are also configured.
The following diagram shows the order in which CEL policies are applied. Each step is optional — if not configured, it is skipped.
Connector Authentication
│
│ upstream claims → connector.Identity
│
v
Authentication Policies
│
│ Global authPolicy
│ Per-client authPolicy
│
v
Token Issuance
│
│ Global tokenPolicy.filter
│ Per-client tokenPolicy.filter
│
│ Global tokenPolicy.claims
│ Per-client tokenPolicy.claims
│
│ Sign JWT
│
v
Token Response
| Step | Policy | Scope | Action on match |
|---|---|---|---|
| 2 | authPolicy (global) | Global | Expression → true = DENY login |
| 3 | authPolicy (per-client) | Per-client | Expression → true = DENY login |
| 4 | tokenPolicy.filter (global) | Global | Expression → false = DENY token |
| 5 | tokenPolicy.filter (per-client) | Per-client | Expression → false = DENY token |
| 6 | tokenPolicy.claims (global) | Global | Adds/overrides claims (with optional condition) |
| 7 | tokenPolicy.claims (per-client) | Per-client | Adds/overrides claims (overrides global) |
| Risk | Mitigation |
|---|---|
| CEL expression complexity / DoS | Cost budgets with configurable limits (default aligned with Kubernetes). Expressions are validated at config load time. Runtime evaluation is aborted if cost exceeds budget. |
| Learning curve for operators | CEL has excellent documentation, playground (cel.dev), and massive CNCF adoption. Dex docs will include a dedicated CEL guide with examples. Most operators already know CEL from Kubernetes. |
cel-go dependency size | cel-go adds ~5MB to binary. This is acceptable for the functionality provided. Kubernetes, Istio, Envoy all accept this trade-off. |
Breaking changes in cel-go | Pin to semver minor range. Environment versioning ensures existing expressions continue to work across upgrades. |
| Security: CEL expression injection | CEL expressions are defined by operators in the server config, not by end users. No CEL expression is ever constructed from user input at runtime. |
| Config migration | Old config fields (ClaimMapping, ClaimMutations) continue to work. CEL expressions are opt-in. If both are specified, CEL takes precedence with a config-time warning. |
| Error messages exposing internals | CEL deny message expressions are controlled by the operator. Default messages are generic. Evaluation errors are logged server-side, not exposed to end users. |
| Performance | Expressions are compiled once at startup. Evaluation is sub-millisecond for typical identity operations. Cost budgets prevent pathological cases. Benchmarks will be included in pkg/cel tests. |
OPA was previously considered (#1635, token exchange DEP). While powerful, it has significant drawbacks for Dex:
github.com/open-policy-agent/opa/rego) is significantly
heavier than cel-go.JMESPath was proposed for claim mapping. Drawbacks:
The current approach: each feature requires new Go structs, config fields, and code. This is unsustainable:
ClaimMapping, NewGroupFromClaims, FilterGroupClaims, ModifyGroupNames are each separate
features that could be one CEL expression.Without CEL or an equivalent: