Ask how to configure an identity provider as code and the answer is usually Terraform. It is often the right answer. Keycloak, authentik and Zitadel all publish Terraform providers that manage OAuth clients, and a team that already plans and applies everything through Terraform has good reasons to keep doing so.
Nauthera has no Terraform provider. Its clients are Kubernetes resources reconciled by an operator, and we chose that model. This article compares the two models for one job, managing the OAuth clients of applications that run on Kubernetes, using the published providers' documentation for the Terraform side. It ends with the cases where Terraform is the better tool.
Two models
Terraform acts when someone runs it. terraform plan reads the current state of the
remote objects it manages, compares them with the configuration, and shows the difference;
terraform apply makes the change and records the result in a state file
(terraform plan). Between
runs, nothing happens.
An operator is a controller that runs in the cluster all the time. It watches the
resources it owns and the system it configures, and acts on every change to either. For
Nauthera, that is the OIDCClient resources in your namespaces on one side and the
Nauthera server on the other.
The differences below follow from that one distinction.
1. It notices drift between runs
Change a client in the identity provider's console, and Terraform finds out at the next
plan. terraform plan -refresh-only shows the change without proposing to undo it, and a
plain apply writes the configuration back over it. If nobody runs a plan for a month, the
change is invisible for a month.
Nauthera's operator probes the server once a minute, and every answer carries a revision
of the server's configuration. Every 15 seconds the operator checks whether that revision
moved; when it has, it lists its clients once and compares each one's revision with the one
it last wrote. A client changed elsewhere is marked Drifted on its resource within about
a minute, and the operator writes nothing over it until the manifest changes or someone
annotates the resource nauthera.io/restore. Undoing a change is a decision, not a side
effect of the next apply.
A scheduled plan in CI gets Terraform part of the way there. The difference that remains is where the answer shows up: in a pipeline log, or on the resource the application team already watches.
2. A generated secret never lands in a state file
Terraform's documentation is direct about this: "Terraform stores your state in a plaintext
file, which includes any secret values you defined in your configuration"
(Manage sensitive data).
A secret the identity provider generates is a computed attribute, so it is read back into
state too. The Keycloak provider's keycloak_openid_client generates one when
client_secret is omitted
(docs),
and authentik's
authentik_
and Zitadel's
zitadel_application_oidc
both document their client_secret as a generated, sensitive attribute.
Terraform 1.11 added write-only arguments, which are never stored in state or plan files,
and the Keycloak provider offers client_secret_wo from its version 5.3.0
(changelog). That closes the state problem, at the
price of supplying the secret yourself, so it is generated and kept somewhere you manage.
Getting it into the application's namespace is then one more resource whose value has to
stay out of state as well.
With the operator, the manifest only asks for a secret. The server mints it and keeps its digest, and the operator writes the plaintext into a Secret in the application's namespace. No file, pipeline or person holds it on the way; the secrets article follows it end to end.
3. No one outside the cluster holds the admin credential
To apply a client, a Terraform provider authenticates to the identity provider with a credential that may create and change clients, scoped as finely as the provider allows. Whoever can run the pipeline, or change what it applies, can change any client that credential reaches. Giving each team its own boundary means a workspace and a credential per team.
The operator holds one credential: a client certificate the server verifies over mutual
TLS. The server grants that identity a list of operations, organizations and namespaces,
and refuses anything outside it. Teams never see the credential. They get Kubernetes RBAC
instead: the chart's nauthera-app-developer role, bound in a team's namespace, lets the
team write OIDCClient and Branding resources there and no other Nauthera kind, and an
Organization decides which namespaces may configure it. See
Who may change what.
4. It refuses at the API and reports on the object
A Terraform provider validates what its schema knows during plan, and the identity
provider refuses the rest during apply, in the pipeline's output. Nauthera's resource
definition carries CEL rules that the Kubernetes API server runs on every write, so a
public client without PKCE is refused at kubectl apply or in the GitOps sync that tried
it (how those rules work).
What only the server can judge comes back on the resource. The Ready condition says
Registered, or False with a reason such as Refused, Drifted or Unreachable, and
the message names the field. kubectl get, an Argo CD health check and a Flux
Kustomization with wait all read the same condition.
5. It follows the application's lifecycle
An OIDCClient can live in the application's own Helm chart. It is created with the app,
changed in the same pull request as the code that needs the change, and deleted with it.
spec.deletionPolicy says what deletion means: the default Retain keeps the client
registered and releases its Secret, so deleting a namespace by mistake cannot take a live
credential with it, while Delete removes the client too. A finalizer holds the resource
until that is done.
A Terraform-managed client has a lifecycle of its own. Removing an application means removing its client from the Terraform configuration as a separate change, in a separate repository or at least a separate apply.
Where Terraform is the better tool
The comparison is not one-sided. Choose Terraform, or keep it, when:
- The identity provider is not in your cluster, or you configure a hosted identity service. An operator manages what its own server serves; Terraform reaches anything with an API.
- One change spans systems. A DNS record, a cloud IAM binding, a database and the client that depends on them can sit in one plan. An operator sees only the cluster.
- You want a plan before every change. Terraform shows what the provider will do
before it does it. With the operator, the review is the manifest's diff in the pull
request and
kubectl diff; what only the server can check is known after the apply. - You need the whole provider surface. The Keycloak provider manages realms, authentication flows, mappers and identity providers. Nauthera's control plane covers six resource kinds, and some client settings, back-channel logout URIs among them, can only be set through dynamic registration today.
- It is already working. A team running Keycloak with Terraform and write-only secrets has little to gain by switching for client management alone.
The two also combine well. Terraform can build the cluster and
install the Nauthera Helm chart and its CRDs, while application
teams ship their OIDCClient resources with their apps. The operator page compares the operator with Terraform and
Ansible situation by situation, and
Changes made outside the operator
documents the drift behavior described here.