Skip to content

matzegebbe/dex-token-broker

Repository files navigation

DexTokenBroker logo

Dex Token Broker

Trading OAuth2 tokens from Dex for Envoy Gateway.

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.

Why this exists

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.

Features

  • Small Go service with no third-party runtime dependencies
  • Designed for Envoy Gateway ext_authz
  • OAuth2 client_credentials support 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 govulncheck and Trivy

Request flow

Standard mode (credential headers)

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
Loading

JWKS validation mode (trusted auth gateway)

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
Loading

Project layout

.
├── .github/
│   ├── dependabot.yml
│   └── workflows/
├── cmd/dextokenbroker/
├── internal/tokenbroker/
├── CHANGELOG.md
├── Dockerfile
├── Makefile
└── README.md

Configuration

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

API

POST /check

Expected request headers by default:

  • x-client-id
  • x-client-secret
  • x-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 Unauthorized for missing or rejected credentials
  • 502 Bad Gateway for invalid responses from Dex
  • 503 Service Unavailable if Dex cannot be reached

GET /healthz

Returns 200 OK with body ok.

JWT/JWKS validation gate

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
  • exp claim is required and must not be in the past
  • nbf claim, if present, must be in the past
  • iss claim, if JWT_ISSUER is configured, must match exactly
  • aud claim, if JWT_AUDIENCE is 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 groups

Token header mapping

By 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-Type

Any 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_token

Local development

Run the broker locally:

go run ./cmd/dextokenbroker

Run tests:

go test ./...

Format code:

make fmt

Build the binary:

make build

Print version information:

go run ./cmd/dextokenbroker --version

Docker

Build 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:dev

Published images are intended for GitHub Container Registry:

ghcr.io/matzegebbe/dextokenbroker

Release tags publish at least these image tags:

  • v1.2.3
  • 1.2.3
  • 1.2
  • latest

Example configuration files:

Envoy Gateway integration

The simplest pattern is to have DexTokenBroker return the final Authorization header and let Envoy forward that header upstream.

Standard mode

  1. The client calls an HTTPRoute on Envoy Gateway.
  2. Envoy sends an ext_authz request to DexTokenBroker at /check.
  3. Envoy forwards x-client-id, x-client-secret, and optionally x-scope to DexTokenBroker.
  4. DexTokenBroker returns Authorization: Bearer <token>.
  5. Envoy forwards that Authorization header 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:
        - Authorization

JWKS validation mode with extra headers

When 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_token

If 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.

Kubernetes example

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: 8080

Cache behavior

The cache stores tokens, not raw credentials.

The cache key is derived from:

  • client_id
  • scope
  • 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.

Security notes

  • 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, set ALLOW_INSECURE_DEX_URL=true.
  • Do not log client secrets.
  • x-client-id, x-client-secret, and x-scope are 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_ID and STATIC_CLIENT_SECRET are intended for that fixed machine-client mode.
  • When JWKS_URL is set, configure JWT_ISSUER and JWT_AUDIENCE to prevent token reuse across services.
  • JWT validation enforces an explicit algorithm allowlist (RS256/384/512, ES256/384/512), rejects alg=none and symmetric algorithms, requires the exp claim, 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.

CI, releases, and automation

This repository is set up for GitHub from day one.

CI

.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

Conventional Commits

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

Semantic Versioning

Releases follow SemVer:

  • fix: -> patch release
  • feat: -> minor release
  • feat!: or BREAKING CHANGE: -> major release

Release Please

.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:

  1. Merge conventional commits into main.
  2. Release Please opens or updates a release PR.
  3. Merge that PR.
  4. Release Please creates the Git tag and GitHub release.
  5. The container workflow publishes the matching Docker image to GHCR.

Container publishing

.github/workflows/container.yml publishes multi-architecture images for:

  • linux/amd64
  • linux/arm64

It also publishes SBOM and provenance attestations with the release image.

Dependabot

.github/dependabot.yml keeps these dependencies current:

  • Go modules
  • GitHub Actions
  • Docker base images

Security workflow

.github/workflows/security.yml runs additional security checks:

  • govulncheck against the Go module graph
  • Trivy filesystem scanning with SARIF upload to GitHub security results

License

Apache License 2.0. See LICENSE.

About

Trading OAuth2 tokens from Dex for Envoy Gateway

Resources

License

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors