A Kubernetes operator (Go, controller-runtime) that assembles the configuration of Dex dynamically from Custom Resources.
Dex itself keeps being installed via the official Dex Helm chart. The operator watches Custom Resources across the cluster and renders two Secrets into the Dex namespace:
- Config Secret — the complete Dex
config.yaml(issuer, storage, web, gRPC, logger, expiry, connectors, static clients) - Env Secret — all credentials as environment variables (e.g.
GRAFANA_CLIENT_SECRET), attached to the Dex container viaenvFromand referenced from the config as$VAR/secretEnv
flowchart LR
subgraph "app namespaces"
SC["DexStaticClient"]
CONN["Dex*Connector"]
SEC[("credential Secrets")]
end
OP["dex-operator"]
subgraph "dex namespace"
INST["DexInstallation"]
CFG[("config Secret")]
ENV[("env Secret")]
DEX["Dex Deployment"]
end
SC --> OP
CONN --> OP
SEC --> OP
INST --> OP
OP --> CFG
OP --> ENV
CFG --> DEX
ENV --> DEX
- 🧩 Declarative Dex configuration — 18 namespace-scoped CRDs under
dex.gtrfc.com/v1; no hand-writtenconfig.yaml - 🔌 16 connector types — LDAP, OIDC, SAML, GitHub, GitLab, Google, Microsoft, and more, each with its own strongly-typed CRD
- 🔐 No secrets in Custom Resources — credentials stay in Kubernetes Secrets and are referenced by name/key; client secrets are injected into Dex as environment variables, never written in plaintext into the rendered config
- 🏢 Multi-tenant by design — apps register their own OAuth2 clients from their own namespaces; two per-installation allowlists decide who may contribute clients and who may contribute connectors (connectors default to the installation's own namespace)
- 🪪 One owner per ID — a client or connector ID renders for exactly one resource, and a client trusts only peers in its own namespace; contested IDs, failing resources and dropped peers are left out and reported instead of breaking the render for everybody
- 🚦 Public (PKCE) and confidential clients — secretless static clients for CLIs/SPAs, validated at admission time via CEL rules (no webhook needed)
- 🔁 Live reconfiguration — changes to any CR or referenced Secret re-render the config; optional automatic rollout restart of the Dex Deployment
- 👀 Secret rotation aware — the operator watches referenced Secrets and reacts to credential rotations automatically
- 🧮 Deterministic output — stable ordering and semantic YAML comparison prevent restart loops and config churn
- 📊 Operational visibility —
Readyconditions, connector/client counts, the list of rejected resources on the installation, andkubectl getprint columns on every resource - 📦 Helm installation — including a pre-upgrade hook job that keeps CRDs up to date
Everything the operator generates follows deterministic naming rules. Knowing them makes debugging and Secret handling predictable.
| Convention | Rule |
|---|---|
| API group / version | dex.gtrfc.com/v1, all CRDs namespace-scoped |
| Connector CRDs | Dex<Provider>Connector, e.g. DexOIDCConnector, DexLDAPConnector |
| Connector ID | spec.id, falling back to metadata.name. The ID appears in Dex login URLs and in generated env var names |
| Installation reference | Every connector and client points to its installation via spec.installationRef (name + namespace) |
kubectl short names: dexinst, dexsc, dexldap, dexgithub, dexsaml, dexgitlab, dexoidc, dexoauth2, dexgoogle, dexlinkedin, dexmicrosoft, dexauthproxy, dexbitbucket, dexlocal, dexopenshift, dexcrowd, dexgitea, dexkeystone.
Credentials are materialized in the env Secret under deterministic names. Names are uppercased and every non-alphanumeric character becomes _:
| Source | Pattern | Example |
|---|---|---|
| Connector client secret | <TYPE>_<ID>_CLIENT_SECRET |
OIDC_KEYCLOAK_CLIENT_SECRET |
| LDAP bind password | LDAP_<ID>_BIND_PW |
LDAP_CORPORATE_LDAP_BIND_PW |
| Keystone admin password | KEYSTONE_<ID>_PASSWORD |
KEYSTONE_OPENSTACK_PASSWORD |
| Storage password | STORAGE_<BACKEND>_PASSWORD |
STORAGE_POSTGRES_PASSWORD |
| Static client secret | <RESOURCE_NAME>_CLIENT_SECRET |
GRAFANA_CLIENT_SECRET (from a DexStaticClient named grafana) |
| Fallback when the name above is taken | <BASE>_<HASH>_<FIELD> — <HASH> is 8 hex characters of SHA-256 over <kind>/<namespace>/<name> |
GRAFANA_1A2B3C4D_CLIENT_SECRET (example — a second grafana in another namespace) |
No two resources ever share an env var. Names are assigned in one order across storage, connectors and static clients — storage first, then resources in the installation's namespace, then the oldest — and a resource whose name is already taken (grafana in two namespaces, my-app and my.app, a client named oidc-okta next to the OIDC connector okta) gets the fallback name and works normally. When the holder of the plain name goes away, it moves back to the plain name (one config change). A name that collides with nothing is the plain name.
| Convention | Rule |
|---|---|
| Static client Secret keys | client-id / client-secret (overridable via secretRef.clientIDKey / secretRef.clientSecretKey) |
| Config Secret content | single key config.yaml |
| Managed Secret labels | app.kubernetes.io/managed-by: dex-operator, dex.gtrfc.com/installation: <name> |
| Derived CORS origin | scheme://host[:port] of every https redirectURIs entry of a DexStaticClient with cors: true — host lowercased, a redundant :443 dropped (e.g. https://dtrack.example.com/static/cb.html → https://dtrack.example.com) |
| Connector cert files | /etc/dex/certs/<connector-id>-<field>.pem (e.g. -root-ca, -client-cert, -client-key, -ca) |
| Google service account | /etc/dex/secrets/<connector-id>-service-account.json |
| Storage TLS files | /etc/dex/certs/postgres-*.pem, etcd-*.pem, mysql-*.pem |
Note: file-based references (SAML CA, client TLS certs, service-account JSON, storage TLS) are rendered into the config at the paths above, but mounting those files into the Dex Pod is currently not automated — add the volumes/volumeMounts in your Dex Helm values. Inline-capable material (LDAP
rootCAData) is embedded base64-encoded and needs no mount.
| Document | Content |
|---|---|
| docs/operations/ | Running the operator: when a change reaches Dex, why a rotated secret needs a Dex restart, mounting file material, reading the conditions, upgrading |
| docs/security/ | The security architecture, one page per perspective: trust boundaries, tenancy, connectors, clients, secret flow, validation, privilege footprint, rotation — each with the gaps it leaves open |
| SECURITY.md | How to report a vulnerability, supported versions |
| docs/developer/ | Repository layout, packages and their responsibilities, architecture and reconcile flow, how to add a connector type, build, test and release |
| docs/adr/ | The design decisions and what was rejected |
| Custom Resource Reference | Every CRD with a fully populated example |
| Dex documentation | Upstream semantics of all connector and OAuth2 options |
helm repo add dex-operator https://guided-traffic.github.io/dex-operator
helm repo update
helm install dex-operator dex-operator/dex-operator \
--namespace dex-operator-system \
--create-namespaceDex must read the operator-generated Secrets instead of an inline config. Minimal working dex-values.yaml:
# dex-values.yaml
configSecret:
create: false
name: dex-config # must match DexInstallation .spec.configSecretName
envFrom:
- secretRef:
name: dex-env # must match DexInstallation .spec.envSecretNamehelm repo add dex https://charts.dexidp.io
helm repo update
helm install dex dex/dex \
--namespace dex \
--create-namespace \
--values dex-values.yamlThe env Secret is created by the operator once a
DexInstallationexists. On first install, create theDexInstallation(step 3) before or right after installing Dex so the referenced Secrets exist.
apiVersion: dex.gtrfc.com/v1
kind: DexInstallation
metadata:
name: main
namespace: dex
spec:
issuer: https://dex.example.com
storage:
type: kubernetes
configSecretName: dex-config
envSecretName: dex-env
allowedNamespaces: ["*"] # static clients from every namespace
# allowedConnectorNamespaces omitted: connectors only from "dex"
rolloutRestart:
enabled: true
deploymentName: dexConfidential client (with client secret) — for server-side apps like Grafana:
apiVersion: v1
kind: Secret
metadata:
name: grafana-oidc
namespace: monitoring
type: Opaque
stringData:
client-id: grafana
client-secret: change-me
---
apiVersion: dex.gtrfc.com/v1
kind: DexStaticClient
metadata:
name: grafana
namespace: monitoring
spec:
installationRef:
name: main
namespace: dex
displayName: Grafana
secretRef:
name: grafana-oidc
redirectURIs:
- https://grafana.example.com/login/generic_oauthPublic client (no secret, PKCE) — for CLIs, native apps, SPAs:
apiVersion: dex.gtrfc.com/v1
kind: DexStaticClient
metadata:
name: my-cli
namespace: monitoring
spec:
installationRef:
name: main
namespace: dex
displayName: My CLI
public: true
clientID: my-cli
# redirectURIs omitted: Dex accepts loopback, OOB and device-flow defaultsKeycloak is a standard OIDC provider, so it plugs in via DexOIDCConnector. In Keycloak, create a confidential client (e.g. dex) with redirect URI https://dex.example.com/callback, then:
apiVersion: v1
kind: Secret
metadata:
name: keycloak-oidc
namespace: dex
type: Opaque
stringData:
client-id: dex
client-secret: keycloak-client-secret
---
apiVersion: dex.gtrfc.com/v1
kind: DexOIDCConnector
metadata:
name: keycloak
namespace: dex
spec:
installationRef:
name: main
namespace: dex
displayName: Keycloak
issuer: https://keycloak.example.com/realms/my-realm
clientIDRef:
name: keycloak-oidc
key: client-id
clientSecretRef:
name: keycloak-oidc
key: client-secret
redirectURI: https://dex.example.com/callbackkubectl get dexinstallation -n dex
# NAME ISSUER STORAGE CONNECTORS CLIENTS READY AGE
# main https://dex.example.com kubernetes 1 2 True 1m
kubectl get secret dex-config dex-env -n dexUpgrade / Uninstall
# Upgrade (CRDs are updated automatically by a pre-upgrade hook job)
helm repo update
helm upgrade dex-operator dex-operator/dex-operator \
--namespace dex-operator-system
# Uninstall
helm uninstall dex-operator --namespace dex-operator-systemUpgrading to the release that introduces allowedConnectorNamespaces:
allowedNamespaces no longer admits connectors; it governs static clients
only. Connectors are admitted by allowedConnectorNamespaces, which defaults
to the DexInstallation's own namespace. Check before upgrading:
- Connectors in a namespace other than the installation's own are dropped
from the config on upgrade and report
Ready=Falsewith... allowedConnectorNamespaces (omitted: only "<ns>" is allowed). List their namespaces inallowedConnectorNamespaces(and the installation's own namespace too, if connectors live there). - Connectors in the installation's own namespace that the old
allowedNamespacesexcluded become active on upgrade, because the new default admits that namespace. Delete such connectors before upgrading if they are not meant to be used. - Installations whose connectors all live in the installation's own namespace,
and whose
allowedNamespacesadmitted it, render byte-identical config: no config diff, no Dex rollout.
Upgrading to the release that makes client and connector IDs unique per installation: check before upgrading:
- Cross-namespace trusted peers stop working. A
trustedPeersentry naming a client in another namespace — or a client that does not exist — is dropped: the config changes, Dex rolls out, and cross-client token requests from that peer fail until both clients live in one namespace (move the client'ssecretRefSecret with it). Find them after the upgrade instatus.droppedTrustedPeersof the installation. - Existing duplicate IDs change the config. After the upgrade a duplicate between the installation's namespace and another one renders only the installation's entry; a duplicate between two other namespaces, or within one namespace, renders neither, and both relying parties fail until one resource is removed or renamed. The same holds for connector IDs, across connector kinds.
- An installation frozen by a failing resource renders again, carrying every change that was held back; the failing resource is left out and Dex rolls out without it.
- The first render patches
status.clientIDon every static client, one status write each. - No duplicate IDs, no failing resource and every
trustedPeersentry held in its own namespace: byte-identical config, no Dex rollout.
Shared concepts for all resources:
installationRef— every connector and static client references exactly oneDexInstallationbyname+namespace. Static clients must come from a namespace in that installation'sallowedNamespaces, connectors from a namespace in itsallowedConnectorNamespaces(default: the installation's own namespace). Resources from other namespaces are ignored and marked withReady=False; the condition follows allowlist changes on the installation.- IDs are unique per installation — client IDs and connector IDs (separate spaces, connectors across all 16 kinds) render for one resource only. An ID renders when exactly one admitted resource claims it, or when exactly one of its claimants is in the installation's namespace; otherwise it renders for nobody. Every claimant that does not render is
Ready=Falsewith reasonDuplicateID, and the installation lists it instatus.rejectedChildrenwith its claimedid— find all claimants of an ID there. Nothing outside the installation's namespace wins a contest, so keep security-relevant clients there. While aDexLocalConnectorexists, the connector IDlocalbelongs to Dex's password database. A confidential client whose Secret is missing keeps claiming the ID in itsstatus.clientID. Moving a client to another namespace needs no particular order: either the old and the new object contest each other until the old one is deleted, or the ID is free until the new one exists — one render goes without the client either way. Details: docs/security/tenancy.md. - A failing resource is left out, not fatal — a connector or client whose Secret or key is missing is skipped with
Ready=False, reasonBuildFailed, and listed instatus.rejectedChildren; everything else renders. Only an unresolvable storage credential stops the render, and so does the connector guard: when connectors exist but none of them renders and noDexLocalConnectordoes, the last written config stays (Dex does not start without a connector) and the installation isReady=False. id(connectors) — the Dex connector ID; defaults tometadata.name.displayName— the human-readable name shown on the Dex login/approval screen.*Reffields —{name, key}references to Kubernetes Secrets in the same namespace as the referencing resource.- Status — all resources report a
Readycondition andobservedGeneration; the installation's and a static client's additional status is in their sections below.
The examples below are maximally populated. Fields are set to their default where one exists; otherwise a realistic example value is shown. Security-relevant fields carry a short note — absence of a note means the field has no direct security impact.
The global Dex configuration. One DexInstallation corresponds to one Dex deployment.
apiVersion: dex.gtrfc.com/v1
kind: DexInstallation
metadata:
name: main
namespace: dex
spec:
# ── Core ──────────────────────────────────────────────────────────────
# Trust anchor of your SSO: the value baked into every issued token.
# Must be the exact HTTPS URL clients use to reach Dex; changing it
# invalidates all existing tokens. (required)
issuer: https://dex.example.com
# ── Storage (required) ────────────────────────────────────────────────
# type: kubernetes | memory | postgres | sqlite3 | etcd | mysql
# "kubernetes" (CRD-based) needs no credentials. "memory" loses all
# sessions/refresh tokens on restart — dev only.
storage:
type: postgres # example (simplest choice: kubernetes)
postgres:
host: postgres.db.svc:5432 # example
database: dex # example
user: dex # example
passwordRef: # password never appears in this CR or in
name: dex-postgres # the rendered config; it is injected as
key: password # env var STORAGE_POSTGRES_PASSWORD
ssl:
# Security: "disable" sends credentials and tokens in cleartext to the
# DB; use verify-ca/verify-full in production.
mode: verify-full # example (disable|require|verify-ca|verify-full)
caRef: { name: dex-postgres-tls, key: ca.crt } # → /etc/dex/certs/postgres-ca.pem
certRef: { name: dex-postgres-tls, key: tls.crt } # → /etc/dex/certs/postgres-client-cert.pem
keyRef: { name: dex-postgres-tls, key: tls.key } # → /etc/dex/certs/postgres-client-key.pem
connectionTimeout: 5 # seconds, example
maxOpenConns: 5 # example
maxIdleConns: 5 # example
connMaxLifetime: 300 # seconds, example
# Other backends (mutually exclusive with postgres):
# sqlite3: { file: /var/dex/dex.db }
# etcd:
# endpoints: ["https://etcd-0:2379"]
# namespace: dex/
# username: dex
# passwordRef: { name: dex-etcd, key: password }
# ssl: { serverName: etcd, caRef: {...}, certRef: {...}, keyRef: {...} }
# mysql: same shape as postgres (password env: STORAGE_MYSQL_PASSWORD)
# ── HTTP endpoints ────────────────────────────────────────────────────
web:
http: 0.0.0.0:5556 # default in the Dex chart
https: 0.0.0.0:5554 # example; requires tlsCert/tlsKey
tlsCert: /etc/dex/tls/tls.crt # example (file must be mounted into Dex)
tlsKey: /etc/dex/tls/tls.key # example
# Security (CORS): only list origins that must call Dex from the browser
# (e.g. SPAs using PKCE). "*" would allow any website to probe Dex.
# Browser clients can instead self-register via DexStaticClient.spec.cors;
# this list stays as the base and escape hatch. The rendered list is this
# list, in order, plus the derived origins appended and sorted.
allowedOrigins:
- https://app.example.com # example
allowedHeaders:
- Authorization # example
# ── gRPC API (optional; used for dynamic client management) ───────────
grpc:
addr: 0.0.0.0:5557 # example
# Security: the gRPC API can create/modify OAuth2 clients. Always front
# it with mTLS (tlsClientCA) and never expose it outside the cluster.
tlsCert: /etc/dex/grpc/tls.crt # example
tlsKey: /etc/dex/grpc/tls.key # example
tlsClientCA: /etc/dex/grpc/ca.crt # example — enables client-cert auth
reflection: false # default
# ── Logging ───────────────────────────────────────────────────────────
logger:
level: info # default (debug|info|warning|error)
format: text # default (text|json)
# ── Token lifetimes ───────────────────────────────────────────────────
expiry:
signingKeys: 6h # Dex default — rotation of JWT signing keys
idTokens: 24h # Dex default — shorter = faster credential
# revocation, more re-authentication
authRequests: 24h # Dex default
deviceRequests: 5m # Dex default
refreshTokens:
# Security: refresh tokens are long-lived credentials. Rotation +
# inactivity windows bound the damage of a stolen token.
disableRotation: false # default — keep rotation on
reuseInterval: 3s # example — tolerates concurrent refreshes
validIfNotUsedFor: 2160h # example (90 days of inactivity)
absoluteLifetime: 3960h # example (hard cap ~165 days)
# ── OAuth2 behaviour ──────────────────────────────────────────────────
oauth2:
responseTypes: ["code"] # default — the code flow; avoid enabling
# implicit ("token") flows unless required
# Security: the approval screen is the user's chance to see which client
# requests access. Skipping it is fine when all clients are first-party.
skipApprovalScreen: false # default
alwaysShowLoginScreen: false # default
grantTypes: ["authorization_code", "refresh_token"] # example (default: all supported)
# Security: enables the password grant against this connector — user
# credentials pass through the client. Leave unset unless a legacy
# client truly needs it.
passwordConnector: local # example
# ── Login UI ──────────────────────────────────────────────────────────
frontend:
dir: /srv/dex/web # example — custom web assets
theme: dark # example
issuer: Example Corp SSO # example — display name on the login page
logoURL: https://example.com/logo.svg # example
# ── Operator wiring ───────────────────────────────────────────────────
configSecretName: dex-config # required — Secret receiving config.yaml
envSecretName: dex-env # required — Secret receiving all env vars
# Security: tenancy control for STATIC CLIENTS only. Only DexStaticClients
# from these namespaces are included. Empty/omitted = deny all (the
# installation's own namespace is not implied). "*" = allow every
# namespace — combine with RBAC on the CRDs (see docs/security/tenancy.md).
allowedNamespaces:
- monitoring # example
- team-a # example
# Security: tenancy control for CONNECTORS (all 16 kinds). A connector adds
# an identity source whose users every client of this installation accepts,
# so admitting a namespace here trusts it with identity issuance for the
# whole installation. Omitted = only this installation's namespace (default
# behaviour, no entry needed). Once set, the list is exhaustive: list the
# own namespace too if connectors live there. "*" must be the only entry;
# [] is rejected. Exact names only; a listed namespace that does not exist
# yet admits whoever creates it first.
allowedConnectorNamespaces:
- dex # example
- platform-idp # example
rolloutRestart:
enabled: true # example (default: false) — restart Dex
deploymentName: dex # when config.yaml changes so file-based
# config is re-read immediatelyStatus, written by the operator (example):
status:
observedGeneration: 3
connectorCount: 2 # rendered connectors, a DexLocalConnector included
staticClientCount: 5 # rendered static clients
# Resources that reference this installation and pass its allowlists, but
# are not in the rendered config. reason: DuplicateID | BuildFailed. The
# message is the one the resource shows in its own Ready condition.
# Security: lists namespaces, names and IDs of every tenant concerned —
# readable by whoever can read this installation.
rejectedChildren:
- kind: DexStaticClient
namespace: team-a
name: app
id: shared-app
reason: DuplicateID
message: client ID "shared-app" is claimed by more than one DexStaticClient of DexInstallation dex/main
- kind: DexStaticClient
namespace: team-b
name: app
id: shared-app
reason: DuplicateID
message: client ID "shared-app" is claimed by more than one DexStaticClient of DexInstallation dex/main
# trustedPeers entries left out because no static client in the trusting
# client's namespace holds the peer ID.
droppedTrustedPeers:
- namespace: monitoring
name: grafana
peerID: argocd
conditions:
- type: Ready # True: rendered and both Secrets written
status: "True"
reason: Reconciled
- type: ChildrenRejected # True while rejectedChildren is non-empty
status: "True"
reason: ChildrenRejected
message: "rejected children: 2, see status.rejectedChildren"
- type: TrustedPeersDropped # True while droppedTrustedPeers is non-empty
status: "True"
reason: TrustedPeersDropped
message: "dropped trustedPeers entries: 1, see status.droppedTrustedPeers"An OAuth2 client registered with Dex. Two modes exist, and the difference matters for security:
| Confidential (with secret) | Public / secretless | |
|---|---|---|
| Spec | secretRef → Secret with client-id/client-secret |
public: true + inline clientID |
| Client authentication | client secret at the token endpoint | none — PKCE (RFC 7636) binds the code to the initiator |
redirectURIs |
required | optional — Dex falls back to loopback, OOB and device-flow URIs |
| Env Secret entry | <NAME>_CLIENT_SECRET (or its fallback name) |
none |
| Typical use | server-side web apps (Grafana, ArgoCD, …) | CLIs, native apps, SPAs |
Why it matters: a confidential client proves its identity with a secret, so only the real backend can redeem authorization codes — the redirect URI allowlist is a second line of defense. A public client cannot hold a secret (anything shipped to a browser or laptop is extractable); its protection rests entirely on PKCE plus redirect-URI control. That trade-off is acceptable for interactive tools, but never model a server-side app as public: you would silently drop the client-authentication layer. Dex supports secretless clients since v2.24.0.
Confidential client, fully populated:
apiVersion: v1
kind: Secret
metadata:
name: grafana-oidc
namespace: monitoring
type: Opaque
stringData:
client-id: grafana
client-secret: change-me # generate: openssl rand -base64 32
---
apiVersion: dex.gtrfc.com/v1
kind: DexStaticClient
metadata:
name: grafana # → env var GRAFANA_CLIENT_SECRET
namespace: monitoring
spec:
installationRef:
name: main
namespace: dex
displayName: Grafana # required — shown on the approval screen
secretRef:
name: grafana-oidc # Secret in the same namespace
clientIDKey: client-id # default
clientSecretKey: client-secret # default
# Security: exact-match allowlist. Broad or attacker-influenced entries
# enable authorization-code interception.
redirectURIs:
- https://grafana.example.com/login/generic_oauth
# Security: the client IDs listed here may obtain ID tokens with THIS
# client's ID as audience (cross-client trust) — every resource server
# that accepts this audience accepts those tokens. Only IDs held by a
# DexStaticClient in this same namespace are rendered; any other entry is
# left out and reported (condition TrustedPeersDropped).
trustedPeers:
- grafana-cli # example — a client in namespace monitoring
public: false # default
cors: false # default — see "Browser clients" below
status: # written by the operator
clientID: grafana # the ID last resolved for this client
# (print column "Client ID"); while the
# Secret is missing, the client keeps
# claiming it
conditions:
- type: Ready # False with reason DuplicateID or
status: "True" # BuildFailed when the installation
reason: Reconciled # left this client out
- type: TrustedPeersDropped # True with the dropped peer IDs
status: "False"
reason: NoneDroppedPublic client, fully populated:
apiVersion: dex.gtrfc.com/v1
kind: DexStaticClient
metadata:
name: my-cli
namespace: monitoring
spec:
installationRef:
name: main
namespace: dex
displayName: My CLI
public: true
clientID: my-cli # inline — a client ID is not confidential
cors: false # default — see "Browser clients" below
# Optional for public clients. Omit to accept Dex's defaults:
# http://localhost:<any-port>, urn:ietf:wg:oauth:2.0:oob, /device/callback
redirectURIs:
- http://127.0.0.1:8085/callback # exampleRendered Dex config (public client) — note the absent secretEnv:
staticClients:
- id: my-cli
name: My CLI
redirectURIs:
- http://127.0.0.1:8085/callback
public: trueAdmission-time validation (CEL, enforced by the API server on kubectl apply):
public |
clientID |
secretRef |
valid | behavior |
|---|---|---|---|---|
false |
— | set | ✅ | confidential client: id and secret from the Secret |
false |
set | — | ❌ | rejected: confidential clients require secretRef |
true |
set | — | ✅ | secretless public client, no env variable emitted |
true |
— | set | ✅ | public client with id + secret from the Secret (PKCE and a secret) |
| any | set | set | ❌ | rejected: ambiguous |
Additionally: confidential clients must set at least one entry in redirectURIs.
Trusted peers stay in the namespace. A trustedPeers entry renders only when a DexStaticClient in the trusting client's own namespace holds that client ID — the one that renders, or the sole claimant whose Secret is missing. An entry naming a client in another namespace, a contested ID, or an ID nobody holds yet is left out of the config, listed in the installation's status.droppedTrustedPeers and shown on the trusting client as TrustedPeersDropped=True; it comes back as soon as a client of that namespace holds the ID. This holds in the installation's namespace too. To let two applications trust each other, put both clients into one namespace.
Browser clients (cors: true): Dex gates its browser-facing endpoints (discovery, token, keys) behind a CORS allowlist. Server-side clients never hit it; an SPA that runs code+PKCE via XHR does. Setting cors: true registers the origins of this client's own https redirectURIs in the installation's web.allowedOrigins, so a browser client can self-register from its own namespace instead of requiring an edit on the DexInstallation:
apiVersion: dex.gtrfc.com/v1
kind: DexStaticClient
metadata:
name: dependency-track
namespace: dependency-track
spec:
installationRef:
name: main
namespace: dex
displayName: Dependency-Track
public: true
clientID: dependency-track
cors: true # opt-in; default false
redirectURIs:
- https://dtrack.example.com/static/oidc-callback.htmlrenders into the installation's config as:
web:
allowedOrigins:
- https://dtrack.example.com # derived — path strippedRules:
- Only
httpsURIs contribute.http://localhost/loopback, custom schemes and the OOB URN are redirect conveniences for native clients, not browser origins, and are skipped silently. UseDexInstallation.spec.web.allowedOriginsfor those. - Origins are derived only from the client's own
redirectURIs— the flag grants no authority beyond that already RBAC-gated field. There is no free-form origin field. - Derived origins are appended to
DexInstallation.spec.web.allowedOrigins(which stays as base and escape hatch) in sorted order; the authored list keeps its order and duplicates collapse. - Derivation is stateless: deleting the client or removing the flag drops the origin on the next render.
- Dex matches origins literally (no subdomain wildcards —
https://*.example.comwould never match). The only wildcard is"*", which can never be produced by derivation. - If the installation has no
spec.webat all, a derived origin creates theweb:block containing onlyallowedOrigins; the listener addresses come from the Dex chart's CLI flags and stay untouched.
Sixteen connector CRDs cover Dex's upstream identity providers. All share installationRef, id (default: metadata.name, unique across all connector kinds of an installation — see installationRef above) and displayName; client secrets always land in the env Secret as <TYPE>_<ID>_CLIENT_SECRET (or its fallback name, see naming conventions), while client IDs are embedded inline (they are not confidential).
| CRD | Dex type |
Credential env vars | File mounts needed |
|---|---|---|---|
| DexLDAPConnector | ldap |
LDAP_<ID>_BIND_PW |
client cert/key (CA is inlined) |
| DexOIDCConnector | oidc |
OIDC_<ID>_CLIENT_SECRET |
optional root CA |
| DexSAMLConnector | saml |
— | IdP signing CA |
| DexGitHubConnector | github |
GITHUB_<ID>_CLIENT_SECRET |
optional root CA (GH Enterprise) |
| DexGitLabConnector | gitlab |
GITLAB_<ID>_CLIENT_SECRET |
— |
| DexGoogleConnector | google |
GOOGLE_<ID>_CLIENT_SECRET |
optional service-account JSON |
| DexMicrosoftConnector | microsoft |
MICROSOFT_<ID>_CLIENT_SECRET |
— |
| DexOAuth2Connector | oauth |
OAUTH2_<ID>_CLIENT_SECRET |
optional root CA |
| DexLinkedInConnector | linkedin |
LINKEDIN_<ID>_CLIENT_SECRET |
— |
| DexBitbucketConnector | bitbucket-cloud |
BITBUCKET_<ID>_CLIENT_SECRET |
— |
| DexGiteaConnector | gitea |
GITEA_<ID>_CLIENT_SECRET |
— |
| DexOpenShiftConnector | openshift |
OPENSHIFT_<ID>_CLIENT_SECRET |
optional root CA |
| DexAtlassianCrowdConnector | atlassian-crowd |
CROWD_<ID>_CLIENT_SECRET |
— |
| DexKeystoneConnector | keystone |
KEYSTONE_<ID>_PASSWORD |
— |
| DexAuthProxyConnector | authproxy |
— | — |
| DexLocalConnector | built-in password DB | — | — |
Authenticates users against an LDAP directory (Active Directory, OpenLDAP, …).
Fully populated example
apiVersion: v1
kind: Secret
metadata:
name: ldap-credentials
namespace: dex
type: Opaque
stringData:
bind-password: my-ldap-password
root-ca.pem: |
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
---
apiVersion: dex.gtrfc.com/v1
kind: DexLDAPConnector
metadata:
name: corporate-ldap
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: corporate-ldap # default: metadata.name
displayName: Corporate LDAP # required
host: ldap.example.com:636 # required (host:port)
# Security: the three flags below each weaken transport security.
insecureNoSSL: false # default — true = plaintext LDAP (389)
insecureSkipVerify: false # default — true = accept any server cert
startTLS: false # default — upgrade 389 via STARTTLS
# CA to verify the LDAP server. Inlined base64 as rootCAData — no mount.
rootCARef: { name: ldap-credentials, key: root-ca.pem }
# Mutual TLS client credentials (mounted as files, see Naming Conventions):
clientCertRef: { name: ldap-credentials, key: tls.crt } # example
clientKeyRef: { name: ldap-credentials, key: tls.key } # example
# Security: use a minimal read-only service account for the bind DN, never
# an admin account. The password is injected as LDAP_CORPORATE_LDAP_BIND_PW.
bindDN: cn=serviceaccount,dc=example,dc=com
bindPWRef: { name: ldap-credentials, key: bind-password }
usernamePrompt: SSO Username # example — login form label
userSearch: # required
baseDN: ou=Users,dc=example,dc=com # required
# Security: the filter scopes who can log in at all — keep it as narrow
# as possible (e.g. a group membership requirement).
filter: "(objectClass=person)" # example
username: sAMAccountName # required — attribute matched to login input
scope: sub # default (sub|one)
idAttr: DN # default — stable user ID claim
emailAttr: mail # default
nameAttr: displayName # example
preferredUsernameAttr: sAMAccountName # example
emailSuffix: example.com # example — for entries without mail attr
groupSearch:
baseDN: ou=Groups,dc=example,dc=com # required
filter: "(objectClass=group)" # example
scope: sub # default (sub|one)
userMatchers: # required, ≥1 — how users map to groups
- userAttr: DN # required
groupAttr: member # required
recursionGroupAttr: memberOf # example — resolves nested groups
nameAttr: cn # default — value of the groups claimGeneric OIDC upstream — Keycloak, Okta, Auth0, Azure AD (OIDC mode), …
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexOIDCConnector
metadata:
name: keycloak
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: keycloak # default: metadata.name
displayName: Keycloak # required
issuer: https://keycloak.example.com/realms/my-realm # required
clientIDRef: { name: keycloak-oidc, key: client-id } # required
clientSecretRef: { name: keycloak-oidc, key: client-secret } # required
# → env OIDC_KEYCLOAK_CLIENT_SECRET
redirectURI: https://dex.example.com/callback # example
scopes: [openid, profile, email, groups] # example; openid always added
getUserInfo: false # default — query the userinfo endpoint
userNameKey: preferred_username # example (default claim: name)
userIDKey: sub # example (default claim: sub)
promptType: consent # example — value of the prompt parameter
overrideClaimMapping: false # default
claimMapping:
preferredUsername: preferred_username # example
email: email # example
groups: groups # example
# Security: skips the email_verified check — an upstream account with an
# unverified (possibly attacker-chosen) email is accepted.
insecureSkipEmailVerified: false # default
# Security: trusts a groups claim outside the discovery contract.
insecureEnableGroups: false # default
basicAuthUnsupported: false # default — force client_secret_post
hostedDomains: [example.com] # example — Google IdPs only
acrValues: ["urn:mace:incommon:iap:silver"] # example
# CA for the IdP's HTTPS endpoint (mounted as file → rootCAs):
rootCARef: { name: keycloak-oidc, key: ca.crt } # exampleSAML 2.0 upstream IdP (POST binding).
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexSAMLConnector
metadata:
name: adfs
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: adfs # default: metadata.name
displayName: Corporate SAML # required
ssoURL: https://adfs.example.com/adfs/ls # required
# Security: the CA that verifies the IdP's assertion signature — this is
# what makes SAML responses trustworthy. Mounted as file → `ca`.
caRef: { name: saml-idp, key: ca.crt }
ssoIssuer: http://adfs.example.com/adfs/services/trust # example
entityIssuer: https://dex.example.com/callback # example — SP entity ID
redirectURI: https://dex.example.com/callback # example — ACS URL
nameIDPolicyFormat: persistent # example
usernameAttr: name # example
emailAttr: email # example
groupsAttr: groups # example
# Security: allowlist on the groups claim — only members may log in.
allowedGroups: [sso-users] # example
# Security: NEVER enable outside local development — accepts forged
# assertions from anyone.
insecureSkipSignatureValidation: false # defaultGitHub / GitHub Enterprise OAuth login.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexGitHubConnector
metadata:
name: github
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: github # default: metadata.name
displayName: GitHub # required
clientIDRef: { name: github-oauth, key: client-id } # required
clientSecretRef: { name: github-oauth, key: client-secret } # required
# → env GITHUB_GITHUB_CLIENT_SECRET
redirectURI: https://dex.example.com/callback # example
hostName: github.example.com # example — GitHub Enterprise (default: github.com)
rootCARef: { name: github-oauth, key: ca.crt } # example — GHE TLS CA (mounted)
# Security: without orgs, ANY GitHub account may log in. Restrict to your
# organizations (and optionally teams).
orgs:
- name: my-org # required per entry
teams: [developers, platform] # example
loadAllGroups: false # default — load all orgs/teams as groups
teamNameField: slug # example (name|slug|both)
useLoginAsID: false # default — true replaces the immutable
# numeric ID with the reusable login nameGitLab.com or self-managed GitLab.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexGitLabConnector
metadata:
name: gitlab
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: gitlab # default: metadata.name
displayName: GitLab # required
baseURL: https://gitlab.example.com # default: https://gitlab.com
clientIDRef: { name: gitlab-oauth, key: client-id } # required
clientSecretRef: { name: gitlab-oauth, key: client-secret } # required
# → env GITLAB_GITLAB_CLIENT_SECRET
redirectURI: https://dex.example.com/callback # example
# Security: without groups, any account on the GitLab instance may log in.
groups: [my-group] # example
useLoginAsID: false # defaultGoogle Workspace / Gmail login, optionally with group claims via the Admin SDK.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexGoogleConnector
metadata:
name: google
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: google # default: metadata.name
displayName: Google # required
clientIDRef: { name: google-oauth, key: client-id } # required
clientSecretRef: { name: google-oauth, key: client-secret } # required
# → env GOOGLE_GOOGLE_CLIENT_SECRET
redirectURI: https://dex.example.com/callback # example
# Security: without hostedDomains, ANY Google account (including private
# Gmail) may log in.
hostedDomains: [example.com] # example
# Security: additional allowlist on Google Groups membership.
groups: [sso-users@example.com] # example
# Service account for the Admin SDK (group lookups). Mounted as file at
# /etc/dex/secrets/<id>-service-account.json. Grant it ONLY the read-only
# Admin SDK scopes it needs.
serviceAccountFileRef: { name: google-sa, key: sa.json } # example
adminEmail: admin@example.com # example — impersonated admin account
fetchTransitiveGroupMembership: false # defaultMicrosoft Entra ID (Azure AD).
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexMicrosoftConnector
metadata:
name: entra
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: entra # default: metadata.name
displayName: Microsoft # required
clientIDRef: { name: entra-oauth, key: client-id } # required
clientSecretRef: { name: entra-oauth, key: client-secret } # required
# → env MICROSOFT_ENTRA_CLIENT_SECRET
redirectURI: https://dex.example.com/callback # example
# Security: "common" accepts accounts from ANY tenant, "consumers" personal
# Microsoft accounts. Pin your tenant ID unless you really are multi-tenant.
tenant: 4f1e-your-tenant-id # example
onlySecurityGroups: false # default
# Security: allowlist on group membership.
groups: [platform-team] # example
groupNameFormat: name # example (id|name); "id" is immutable,
# "name" is human-readable but renameable
domainHint: example.com # example — pre-selects the tenantGeneric OAuth2 provider without OIDC discovery — endpoints are configured manually.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexOAuth2Connector
metadata:
name: legacy-idp
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: legacy-idp # default: metadata.name
displayName: Legacy IdP # required
clientIDRef: { name: legacy-oauth, key: client-id } # required
clientSecretRef: { name: legacy-oauth, key: client-secret } # required
# → env OAUTH2_LEGACY_IDP_CLIENT_SECRET
redirectURI: https://dex.example.com/callback # example
# Security: no discovery document means YOU vouch for these endpoints.
# A wrong/spoofed tokenURL would receive your client secret.
authorizationURL: https://idp.example.com/oauth/authorize # required
tokenURL: https://idp.example.com/oauth/token # required
userInfoURL: https://idp.example.com/oauth/userinfo # example
scopes: [openid, profile, email] # example
rootCARef: { name: legacy-oauth, key: ca.crt } # example — mounted → rootCAs
# Security: disables TLS verification towards the provider — dev only.
insecureSkipVerify: false # default
userIDKey: id # default
claimMapping:
userName: user_name # default
preferredUsername: preferred_username # example
email: email # example
emailVerified: email_verified # example
groups: groups # exampleLinkedIn login.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexLinkedInConnector
metadata:
name: linkedin
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: linkedin # default: metadata.name
displayName: LinkedIn # required
clientIDRef: { name: linkedin-oauth, key: client-id } # required
clientSecretRef: { name: linkedin-oauth, key: client-secret } # required
# → env LINKEDIN_LINKEDIN_CLIENT_SECRET
redirectURI: https://dex.example.com/callback # example
# Note: LinkedIn offers no org/group restriction — any LinkedIn account can
# authenticate. Gate authorization in your applications.Bitbucket Cloud login.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexBitbucketConnector
metadata:
name: bitbucket
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: bitbucket # default: metadata.name
displayName: Bitbucket # required
clientIDRef: { name: bitbucket-oauth, key: client-id } # required
clientSecretRef: { name: bitbucket-oauth, key: client-secret } # required
# → env BITBUCKET_BITBUCKET_CLIENT_SECRET
redirectURI: https://dex.example.com/callback # example
# Security: without teams, any Bitbucket account may log in.
teams: [my-workspace] # exampleGitea login.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexGiteaConnector
metadata:
name: gitea
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: gitea # default: metadata.name
displayName: Gitea # required
baseURL: https://gitea.example.com # required
clientIDRef: { name: gitea-oauth, key: client-id } # required
clientSecretRef: { name: gitea-oauth, key: client-secret } # required
# → env GITEA_GITEA_CLIENT_SECRET
redirectURI: https://dex.example.com/callback # example
# Security: without orgs, any account on the Gitea instance may log in.
orgs:
- name: My Org # required per entry (full name)
teams: [developers] # example
loadAllGroups: false # default
useLoginAsID: false # defaultOpenShift OAuth server as upstream IdP.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexOpenShiftConnector
metadata:
name: openshift
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: openshift # default: metadata.name
displayName: OpenShift # required
issuer: https://api.openshift.example.com:6443 # required — API server URL
clientIDRef: { name: openshift-oauth, key: client-id } # required
clientSecretRef: { name: openshift-oauth, key: client-secret } # required
# → env OPENSHIFT_OPENSHIFT_CLIENT_SECRET
redirectURI: https://dex.example.com/callback # example
# Security: allowlist on OpenShift group membership.
groups: [cluster-users] # example
# Security: skips API server certificate verification — dev only.
insecureCA: false # default
rootCARef: { name: openshift-oauth, key: ca.crt } # example — mountedAtlassian Crowd directory.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexAtlassianCrowdConnector
metadata:
name: crowd
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: crowd # default: metadata.name
displayName: Atlassian Crowd # required
baseURL: https://crowd.example.com/crowd # required
# clientIDRef holds the Crowd APPLICATION NAME, clientSecretRef the
# application password (→ env CROWD_CROWD_CLIENT_SECRET).
clientIDRef: { name: crowd-app, key: client-id } # required
clientSecretRef: { name: crowd-app, key: client-secret } # required
# Security: allowlist on Crowd group membership.
groups: [jira-users] # example
preferredUsernameField: name # example (key|name|email)
usernamePrompt: Crowd Login # exampleOpenStack Keystone identity service.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexKeystoneConnector
metadata:
name: openstack
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: openstack # default: metadata.name
displayName: OpenStack # required
keystoneHost: https://keystone.example.com:5000 # required
domain: default # example — restricts the Keystone domain
# Security: admin credentials used for user/group lookups — scope this
# account minimally. Password → env KEYSTONE_OPENSTACK_PASSWORD.
keystoneUsername: demo # example
keystonePasswordRef: { name: keystone-admin, key: password } # exampleDelegates authentication to a trusted reverse proxy that sets identity headers.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexAuthProxyConnector
metadata:
name: authproxy
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: authproxy # default: metadata.name
displayName: SSO Proxy # required
# Security: Dex trusts these headers UNCONDITIONALLY. The proxy MUST be the
# only network path to Dex's /callback for this connector, and it must strip
# client-supplied copies of these headers — otherwise anyone can forge any
# identity.
userIDHeader: X-Remote-User-ID # default
userHeader: X-Remote-User # default
userNameHeader: X-Remote-User-Display-Name # default
emailHeader: X-Remote-User-Email # default
groupHeader: X-Remote-Group # default
groupHeaderSeparator: "," # example
staticGroups: [proxy-users] # example — appended to every loginEnables Dex's built-in password database (enablePasswordDB: true). Intended for bootstrap and testing; prefer a real IdP in production.
Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexLocalConnector
metadata:
name: local
namespace: dex
spec:
installationRef: { name: main, namespace: dex }
id: local # default: metadata.name
displayName: Email # required — label on the login formmake build # build the manager binary
make run # run locally against the current kubeconfig
make generate-all # regenerate CRDs, DeepCopy code and sync the Helm chart
make lint # go vet + gofmt + golangci-lint
make test # unit + envtest suitesSee docs/developer/ for the full repository walkthrough, test matrix (unit / integration / e2e) and release process.
Apache License 2.0 — see LICENSE.