DexTokenBroker is a lightweight external authorization service for Envoy Gateway. It performs OAuth2 client_credentials requests against Dex, caches access tokens in memory, and returns Authorization: Bearer ... headers that Envoy can forward to backend services.
The project is built for the ext_authz pattern: Envoy calls DexTokenBroker first, DexTokenBroker fetches or reuses a token, and the backend receives an already-authorized request.
Envoy Gateway is good at routing, policy enforcement, and header forwarding, but it does not natively execute OAuth2 client flows. If your backend trusts Dex-issued access tokens and Dex is only reachable inside the cluster, you need a small broker between Envoy and Dex.
DexTokenBroker fills that gap.
- Small Go service with no third-party runtime dependencies
- Designed for Envoy Gateway
ext_authz - OAuth2
client_credentialssupport against Dex - Optional JWT/JWKS validation gate to restrict access to trusted callers
- Configurable mapping of token response fields to upstream headers
- In-memory token cache with periodic cleanup
- Bounded cache size with a simple eviction policy
- Cache key includes a hash of the client secret, so rotated or incorrect secrets do not reuse another token
- In-flight request deduplication to avoid token refresh storms
- TLS-to-Dex by default, with an explicit insecure opt-out for local or trusted-network testing
- Strict request validation and bounded upstream response parsing
- Configurable inbound and outbound header names
- Optional static credentials mode for secret injection from the runtime environment
- Stateless per pod
- Ready for Docker and Kubernetes
- GitHub Actions CI
- GitHub Container Registry publishing
- Release Please for SemVer + Conventional Commits
- Dependabot for Go modules, Docker, and GitHub Actions
- Security workflow with
govulncheckand Trivy
sequenceDiagram
participant C as Client
participant E as Envoy Gateway
participant B as DexTokenBroker
participant D as Dex
participant S as Backend Service
C->>E: Request with x-client-id / x-client-secret
E->>B: ext_authz /check
B->>B: Check in-memory cache
alt cache miss
B->>D: POST /token (client_credentials)
D-->>B: access_token
B->>B: Cache token until shortly before expiry
end
B-->>E: 200 OK + Authorization header
E->>S: Forward request with Authorization: Bearer <token>
S-->>C: Response
When JWKS_URL is set, DexTokenBroker validates incoming JWTs before exchanging static credentials with Dex:
sequenceDiagram
participant C as Client (with JWT)
participant E as Envoy Gateway
participant B as DexTokenBroker
participant J as JWKS Endpoint
participant D as Dex
participant S as Backend Service
C->>E: Request with Authorization: Bearer <jwt>
E->>B: ext_authz /check (forwards Authorization header)
B->>B: Validate JWT signature against cached JWKS
alt JWKS not cached or kid unknown
B->>J: GET JWKS
J-->>B: Public keys
end
alt JWT invalid
B-->>E: 401 Unauthorized
end
B->>B: JWT valid, use static credentials
B->>B: Check token cache
alt cache miss
B->>D: POST /token (client_credentials + scope)
D-->>B: access_token
B->>B: Cache token
end
B-->>E: 200 OK + Authorization + extra headers
E->>S: Forward with Authorization: Bearer <dex_token>
S-->>C: Response
.
├── .github/
│ ├── dependabot.yml
│ └── workflows/
├── cmd/dextokenbroker/
├── internal/tokenbroker/
├── CHANGELOG.md
├── Dockerfile
├── Makefile
└── README.md
DexTokenBroker is configured with environment variables:
| Variable | Default | Description |
|---|---|---|
LISTEN_ADDR |
:8080 |
HTTP listen address |
DEX_TOKEN_URL |
https://dex.dex.svc.cluster.local/token |
Dex OAuth2 token endpoint |
HTTP_TIMEOUT |
5s |
Timeout for outbound token requests |
CACHE_CLEANUP_INTERVAL |
5m |
How often expired tokens are removed |
EXPIRY_SAFETY_MARGIN |
30s |
Buffer subtracted from expires_in before a token is treated as expired |
CACHE_MAX_ENTRIES |
1024 |
Maximum number of cached token entries; 0 disables caching |
ALLOW_INSECURE_DEX_URL |
false |
Allow plain http:// URLs for Dex and JWKS endpoints |
LOG_LEVEL |
INFO |
Log level for the service logger |
SHUTDOWN_TIMEOUT |
10s |
Graceful shutdown timeout |
UPSTREAM_AUTH_HEADER |
Authorization |
Header returned to Envoy for the backend request |
UPSTREAM_TOKEN_HEADERS |
empty | Map token response JSON fields to extra response headers (see Token header mapping) |
CLIENT_ID_HEADER |
x-client-id |
Header name used to read the OAuth client ID |
CLIENT_SECRET_HEADER |
x-client-secret |
Header name used to read the OAuth client secret |
SCOPE_HEADER |
x-scope |
Header name used to read the OAuth scope |
STATIC_CLIENT_ID |
empty | Fixed OAuth client ID; overrides the incoming client-ID header |
STATIC_CLIENT_SECRET |
empty | Fixed OAuth client secret; overrides the incoming client-secret header |
STATIC_SCOPE |
empty | Fixed OAuth scope; overrides the incoming scope header (e.g. openid email profile groups) |
JWKS_URL |
empty | JWKS endpoint URL; when set, incoming requests must carry a valid JWT (see JWT/JWKS validation) |
JWT_HEADER |
Authorization |
Header to read the incoming JWT from |
JWT_ISSUER |
empty | If set, reject JWTs whose iss claim does not match |
JWT_AUDIENCE |
empty | If set, reject JWTs whose aud claim does not contain this value |
Expected request headers by default:
x-client-idx-client-secretx-scope(optional)
Those names can be changed with CLIENT_ID_HEADER, CLIENT_SECRET_HEADER, and SCOPE_HEADER.
Success response:
HTTP/1.1 200 OK
Authorization: Bearer <access_token>Failure responses:
401 Unauthorizedfor missing or rejected credentials502 Bad Gatewayfor invalid responses from Dex503 Service Unavailableif Dex cannot be reached
Returns 200 OK with body ok.
When JWKS_URL is set, DexTokenBroker acts as a trusted-auth gateway: every request to /check must carry a valid JWT in the configured header (JWT_HEADER, default Authorization). The broker validates the JWT signature against the JWKS endpoint and checks standard claims before exchanging static credentials with Dex.
This mode requires STATIC_CLIENT_ID to be set. STATIC_CLIENT_SECRET is optional — if omitted, the secret is read from the incoming request header as usual. The broker uses the resolved credentials for all Dex token requests once the incoming JWT is verified.
What is validated:
- JWT signature against public keys from the JWKS endpoint (RSA and ECDSA)
- Algorithm allowlist: only
RS256,RS384,RS512,ES256,ES384,ES512 - Algorithm must match the key's registered algorithm in JWKS
expclaim is required and must not be in the pastnbfclaim, if present, must be in the pastissclaim, ifJWT_ISSUERis configured, must match exactlyaudclaim, ifJWT_AUDIENCEis configured, must contain the expected value- Minimum RSA key size of 2048 bits
- Maximum JWT size of 16 KB
JWKS key caching:
Keys are fetched lazily on the first request and cached in memory. If a JWT presents an unknown kid, the broker refreshes the JWKS endpoint (rate-limited to once per 5 minutes to prevent abuse).
Example configuration:
JWKS_URL=https://auth.example.com/.well-known/jwks.json
JWT_HEADER=Authorization
JWT_ISSUER=https://auth.example.com
JWT_AUDIENCE=my-service
STATIC_CLIENT_ID=dex-client
STATIC_CLIENT_SECRET=dex-secret
STATIC_SCOPE=openid email profile groupsBy default, the broker returns a single Authorization: Bearer <token> header. With UPSTREAM_TOKEN_HEADERS, you can expose additional fields from the Dex token response as separate response headers. Envoy's headersToBackend can then forward them to the backend.
Format: comma-separated json_field:header_name pairs. If :header_name is omitted, the JSON field name is used as the header name.
Examples:
# Expose access_token as a raw header (no Bearer prefix)
UPSTREAM_TOKEN_HEADERS=access_token
# Map to a custom header name
UPSTREAM_TOKEN_HEADERS=access_token:X-Access-Token
# Multiple mappings
UPSTREAM_TOKEN_HEADERS=access_token,token_type:X-Token-TypeAny string or numeric field from the Dex token response JSON can be mapped. The mapped values are cached alongside the token, so no extra overhead on cache hits.
Envoy Gateway SecurityPolicy with extra headers:
extAuth:
headersToExtAuth:
- Authorization
http:
backendRefs:
- name: dex-token-broker
port: 8080
path: /check
headersToBackend:
- Authorization
- access_tokenRun the broker locally:
go run ./cmd/dextokenbrokerRun tests:
go test ./...Format code:
make fmtBuild the binary:
make buildPrint version information:
go run ./cmd/dextokenbroker --versionBuild the container locally:
docker build -t dextokenbroker:dev .Run it:
docker run \
-p 8080:8080 \
-e DEX_TOKEN_URL=https://dex.dex.svc.cluster.local/token \
dextokenbroker:devPublished images are intended for GitHub Container Registry:
ghcr.io/matzegebbe/dextokenbroker
Release tags publish at least these image tags:
v1.2.31.2.31.2latest
Example configuration files:
The simplest pattern is to have DexTokenBroker return the final Authorization header and let Envoy forward that header upstream.
- The client calls an
HTTPRouteon Envoy Gateway. - Envoy sends an
ext_authzrequest to DexTokenBroker at/check. - Envoy forwards
x-client-id,x-client-secret, and optionallyx-scopeto DexTokenBroker. - DexTokenBroker returns
Authorization: Bearer <token>. - Envoy forwards that
Authorizationheader to the backend service.
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: dex-token-broker
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: my-api
extAuth:
headersToExtAuth:
- x-client-id
- x-client-secret
- x-scope
http:
backendRefs:
- name: dex-token-broker
port: 8080
path: /check
headersToBackend:
- AuthorizationWhen JWKS_URL is set, the broker validates the caller's JWT and exchanges fixed credentials with Dex. Use UPSTREAM_TOKEN_HEADERS to expose additional token fields as headers that Envoy can forward.
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: dex-token-broker
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: my-api
extAuth:
headersToExtAuth:
- Authorization
http:
backendRefs:
- name: dex-token-broker
port: 8080
path: /check
headersToBackend:
- Authorization
- access_tokenIf you change UPSTREAM_AUTH_HEADER, Envoy must forward that header name instead.
Field names and placement have shifted across some Envoy Gateway releases, so treat the YAML above as the target pattern and align it with the exact version of Envoy Gateway you deploy.
Minimal Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: dex-token-broker
spec:
replicas: 2
selector:
matchLabels:
app: dex-token-broker
template:
metadata:
labels:
app: dex-token-broker
spec:
containers:
- name: dex-token-broker
image: ghcr.io/matzegebbe/dextokenbroker:latest
ports:
- containerPort: 8080
env:
- name: DEX_TOKEN_URL
value: https://dex.dex.svc.cluster.local/token
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
readinessProbe:
httpGet:
path: /healthz
port: 8080
livenessProbe:
httpGet:
path: /healthz
port: 8080The cache stores tokens, not raw credentials.
The cache key is derived from:
client_idscope- SHA-256 hash of
client_secret
That keeps the service stateless while preventing a token minted for one secret from being reused by a different secret for the same client ID.
The cache is bounded by CACHE_MAX_ENTRIES. When the cache reaches capacity, DexTokenBroker first removes expired entries and then evicts the entry that expires soonest.
Expired tokens are removed in two ways:
- lazily on read when an expired entry is accessed
- periodically by a background cleanup goroutine
The broker also deduplicates concurrent cache misses per cache key, which helps avoid a burst of identical /token requests when a token expires under load.
- Always use TLS between clients, Envoy Gateway, DexTokenBroker, and Dex.
- DexTokenBroker rejects insecure
http://Dex and JWKS endpoints by default. If you intentionally run them over plain HTTP, setALLOW_INSECURE_DEX_URL=true. - Do not log client secrets.
x-client-id,x-client-secret, andx-scopeare length-limited and rejected if they contain control characters.- Dex token responses are size-limited and the broker rejects non-Bearer token types.
- If all traffic should use one fixed machine client, prefer storing the credentials in Kubernetes Secrets and letting DexTokenBroker own them instead of forwarding credentials from external clients.
STATIC_CLIENT_IDandSTATIC_CLIENT_SECRETare intended for that fixed machine-client mode.- When
JWKS_URLis set, configureJWT_ISSUERandJWT_AUDIENCEto prevent token reuse across services. - JWT validation enforces an explicit algorithm allowlist (RS256/384/512, ES256/384/512), rejects
alg=noneand symmetric algorithms, requires theexpclaim, enforces minimum RSA key sizes (2048 bits), and rate-limits JWKS refresh to prevent endpoint abuse. - The in-memory cache is pod-local by design. That keeps the service simple, but each replica has its own cache.
- The published container image is non-root, distroless, emits SBOM/provenance on release, and is scanned in CI.
This repository is set up for GitHub from day one.
.github/workflows/ci.yml runs on pushes to main and on pull requests. It:
- checks
gofmt - runs
go test ./... - runs
govulncheck ./... - builds the binary
- builds the Docker image
Commit messages should follow Conventional Commits so Release Please can determine the next semantic version.
Examples:
feat: add optional static credentials mode
fix: return 503 when dex is unavailable
docs: expand envoy gateway integration guide
chore(ci): update build-push-action
Releases follow SemVer:
fix:-> patch releasefeat:-> minor releasefeat!:orBREAKING CHANGE:-> major release
.github/workflows/release-please.yml and the two .release-please-* files manage releases.
If you want the release tag and GitHub release created by Release Please to trigger downstream workflows such as the container publish job, create a repository secret named RELEASE_PLEASE_PAT. The workflow uses that secret when present and falls back to GITHUB_TOKEN otherwise.
Flow:
- Merge conventional commits into
main. - Release Please opens or updates a release PR.
- Merge that PR.
- Release Please creates the Git tag and GitHub release.
- The container workflow publishes the matching Docker image to GHCR.
.github/workflows/container.yml publishes multi-architecture images for:
linux/amd64linux/arm64
It also publishes SBOM and provenance attestations with the release image.
.github/dependabot.yml keeps these dependencies current:
- Go modules
- GitHub Actions
- Docker base images
.github/workflows/security.yml runs additional security checks:
govulncheckagainst the Go module graph- Trivy filesystem scanning with SARIF upload to GitHub security results
Apache License 2.0. See LICENSE.
