Many teams that run Keycloak manage its clients with Terraform. This page follows one
client, a web shop that signs its users in with the authorization code flow and a client
secret, through that workflow and through a Nauthera OIDCClient: how it is defined, how
its secret reaches the application, what happens when someone changes it by hand, how it
is deleted and how a change is reviewed. Nauthera is pre-1.0, in a design-partner program: its signed server and operator images and its Helm chart are public on the GitHub Container Registry, and teams can apply for one of five design-partner seats to pilot it with direct support. The source is private.
Checked against: the keycloak/keycloak Terraform provider 5.10.0 (published
October 5, 2026), its
keycloak_openid_client docs
and resource code
at that tag; the Terraform v1.17 docs; Crossplane v2.4 docs and provider-keycloak v3.2.3;
the keycloak-config-cli README on its main branch; all read on November 4, 2026. The
Nauthera manifest is checked against nauthera-server's OIDCClient definition. Neither
workflow has been run end to end for this page.
At a glance
| Keycloak with Terraform 5.10.0 | Nauthera | |
|---|---|---|
| Definition | A keycloak_openid_client resource in a Terraform configuration (provider docs). | An OIDCClient manifest beside the application. |
| Secret | Generated by Keycloak and read back into state, which "Terraform stores ... in a plaintext file" (sensitive data); or supplied by you, write-only, with client_secret_wo. | Generated by the server, which keeps a hash; the operator writes it into a Secret. Or supplied by you from a Secret the operator reads (spec.secret.secretRef), hashed the same way. |
| Drift | Found when someone runs terraform plan, which first reads "the current state of any already-existing remote objects" (plan). | Noticed by the operator, reported as Drifted, and left until someone restores it. |
| Deletion | Removing the block makes apply destroy the client (resource behavior). | deletionPolicy: Retain by default, or Delete. |
| Review | The plan, with the secret shown as (sensitive value) (sensitive data). | The manifest's diff, and a server-side dry run against the CRD's rules. |
| Scope | Most of Keycloak's configuration: realms, authentication flows, protocol mappers, identity providers and clients, each a resource (resource docs). | OAuth clients and the five other nauthera.io resources. |
The client
The shop runs in the shop namespace at https://shop.example.com. It is a confidential
client: it signs users in with the authorization code flow and PKCE, and authenticates to
the token endpoint with a client secret.
1. Definition
With Terraform, the client is a resource in a configuration that holds the Keycloak provider's admin credentials:
resource "keycloak_realm" "shop" {
realm = "shop"
enabled = true
}
resource "keycloak_openid_client" "shop" {
realm_id = keycloak_realm.shop.id
client_id = "shop"
name = "Shop"
access_type = "CONFIDENTIAL"
standard_flow_enabled = true
valid_redirect_uris = ["https://shop.example.com/callback"]
valid_post_logout_redirect_uris = ["https://shop.example.com/"]
pkce_code_challenge_method = "S256"
}With Nauthera, the client is a manifest in the application's own repository, next to its Deployment:
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
name: shop
namespace: shop
spec:
clientId: shop
displayName: Shop
redirectUris:
- https://shop.example.com/callback
postLogoutRedirectUris:
- https://shop.example.com/
grantTypes: [authorization_code, refresh_token]
responseTypes: [code]
scopes: [openid, profile, email, offline_access]
tokenEndpointAuthMethod: client_secret_basic
pkcePolicy: required_s256
allowRefresh: true
secret:
generate: true
deletionPolicy: RetainIt configures the organization that admits the shop namespace, so whoever may create
resources there may register clients for that organization and no other
(Which organization a resource configures).
The Terraform configuration, by contrast, can change whatever its admin credentials can.
2. Secret delivery
Terraform, generated secret. Leave client_secret out and Keycloak generates one.
The attribute is optional, computed and sensitive, and the provider sets it from
Keycloak's answer on every read
(resource code),
so the secret lands in the state file, where "Terraform stores your state in a plaintext
file, which includes any secret values"
(sensitive data).
Reaching the shop is then a second step: a kubernetes_secret_v1 resource, whose data
"will be stored in the raw state as plain-text" too
(docs,
hashicorp/kubernetes 3.4.0), or a secret store.
Terraform, write-only secret. With Terraform 1.11 or later, client_secret_wo and
client_secret_wo_version send a secret you supply without storing it in state or plan
files. The provider's example generates it with an ephemeral resource:
ephemeral "random_password" "shop" {
length = 32
}
resource "keycloak_openid_client" "shop" {
realm_id = keycloak_realm.shop.id
client_id = "shop"
access_type = "CONFIDENTIAL"
standard_flow_enabled = true
valid_redirect_uris = ["https://shop.example.com/callback"]
client_secret_wo = ephemeral.random_password.shop.result
client_secret_wo_version = "1"
}The application still needs the same value, delivered by another write-only path, such as
data_wo on that same kubernetes_secret_v1 resource, or a secret store. Rotating means a new value and a new client_secret_wo_version.
Crossplane. provider-keycloak v3.2.3 is generated from the same Terraform provider
(README).
Its client takes the secret as input from a Secret you write, through
clientSecretSecretRef
(client types),
and Crossplane writes a resource's connection details into a Secret named by
writeConnectionSecretToRef
(managed resources).
Nauthera. With secret.generate: true the server mints the secret, returns it to the
operator once and keeps only a hash. The operator writes it into the Secret shop/shop
under client-id, client-secret and issuer, and the Deployment reads it:
env:
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: shop
key: client-secretNo one handles the value, and it is in no state file or repository. Deleting the Secret
rotates it: the operator mints a new one, writes it, and records a SecretRotated event.
See How secrets work.
3. Drift
Someone changes the client by hand: in Keycloak's admin console, or on Nauthera with
nauthera-cli under the operator's client certificate, which writes through the same
control plane as the operator.
Terraform sees it the next time someone runs terraform plan, which first reads "the
current state of any already-existing remote objects", and the next apply writes the
configuration back; -refresh-only accepts the change into state instead
(plan). Between runs,
nothing notices.
Crossplane checks on a poll interval and "overrides any changes made to an external resource outside of Crossplane" (managed resources, v2.4).
keycloak-config-cli applies a realm file through the admin API each time it runs. By default it "will delete and recreate resources that it initially created in previous runs", and for some resource types it deletes anything the file leaves out (managed resources).
Nauthera's operator notices that the server's revision of the client moved without
it, marks the resource Drifted, and does not write over the change. The audit log says
who made it. To keep the manifest's version, annotate the resource:
kubectl annotate oidcclient shop -n shop nauthera.io/restore=yesThe operator writes the declared state back, records a Restored event and removes the
annotation. Changing the manifest has the same effect. See
Changes made outside the operator.
4. Deletion
Terraform "destroys resources that exist in the state but no longer exist in the
configuration"
(resource behavior),
so deleting the block and applying removes the client from Keycloak. A client declared
with import = true is left in place
(provider docs).
Nauthera follows deletionPolicy. Retain, the default, leaves the client
registered and releases the Secret from the resource, so deleting a namespace cannot take
a live client with it. Delete removes the client from the server, and the Secret goes
with the resource; a removal the server does not take keeps the resource in Deleting
until it does. See Deleting a resource.
5. Review
Terraform: a pull request shows the configuration's diff, and the plan shows what will
change against Keycloak as it is now, with the secret as (sensitive value). Applying it
needs the provider's Keycloak admin credentials wherever the plan runs.
Nauthera: the pull request is the manifest's diff, in the application's own
repository. kubectl apply --dry-run=server runs the CRD's validation, which refuses a
contradictory client, such as a public client without required_s256 or a
refresh_token grant without allowRefresh. After the merge, your GitOps tool applies
it, and the resource's Ready condition says whether the server took it:
kubectl wait --for=condition=Ready oidcclient/shop -n shopWhere Terraform is the better choice
- You keep Keycloak. Today that is every production deployment. Nauthera is pre-1.0, in a design-partner program: its signed server and operator images and its Helm chart are public on the GitHub Container Registry, and teams can apply for one of five design-partner seats to pilot it with direct support. The source is private. The provider manages realms, flows, mappers and identity providers, not only clients, and supports "the latest three minor versions of Keycloak" (provider README).
- Your clients belong to more than Kubernetes. Terraform declares a client beside the DNS record, the database and the cloud resources it goes with, in one plan.
- You want every change planned against live state before it is applied, by a person, rather than reconciled by a controller.
- You already keep secrets out of state, with
client_secret_woand a write-only or external delivery. Then the secret argument for an operator is gone, and what remains is drift between runs and the second step that delivers the secret.
The operator page compares the two models in general: Why a Kubernetes operator. Moving clients and users from Keycloak is covered under Migrating from Keycloak.