The usual life of an OIDC client secret goes like this. Someone creates the client in the identity provider's console, copies the secret into a password manager, and later pastes it into a Kubernetes Secret, or into a sealed or encrypted file in Git so the GitOps tool can create that Secret. Tools like Sealed Secrets, SOPS and External Secrets make the last step safe. None of them removes the person who copied the secret in the first place.
Nauthera's operator takes a different route: nobody handles the secret at all. The manifest asks for one, the server mints it, and the operator writes it straight into the Secret the workload reads. This article follows one secret through that path, shows how to check which secret a Secret holds without reading it, and how rotation works.
The manifest asks, the server mints
An OIDCClient never contains a secret. It contains a request for one, from a namespace an
organization admits (the chart's operator. for the main one; see
Which organization a resource configures):
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
name: storefront-web
namespace: storefront
spec:
clientId: storefront-web
redirectUris:
- https://shop.example.com/callback
grantTypes: [authorization_code]
scopes: [openid, profile, email]
secret:
generate: trueThe method defaults to client_secret_basic. When the operator sends this spec over the
control plane, the server generates the secret from 32 random bytes, encoded as 43
URL-safe characters, and stores only its SHA-256 digest. A slow password hash would add
nothing for a value with 256 bits of entropy. The plaintext travels back exactly once, in
the response to the call that minted it. Applying the same spec again returns no secret
and never rotates it.
The definition refuses generate: true on a public client (tokenEndpointAuthMethod:) and on a private_key_jwt client, which authenticate without one.
What the operator writes
The operator writes the plaintext into a Secret in the resource's own namespace, named by
spec.secret.secretName or, as here, after the resource. The Secret is of type Opaque
and holds three keys:
| Key | Value |
|---|---|
client-id | The client ID the server registered |
client-secret | The secret |
issuer | The issuer of the organization the client belongs to |
It is labelled app., carries an owner
reference to the OIDCClient, and is annotated nauthera.. The
resource records where it went in status.secretRef (the Secret's name and the key
client-secret) and the same fingerprint in status.secretFingerprint.
The fingerprint is how you tell which secret a Secret holds without reading it. It is 11 characters derived from the digest the server stores, not from the plaintext, so it cannot be reversed and is safe in status, logs and events. It changes when, and only when, the secret changes. Compare the two:
kubectl -n storefront get oidcclient storefront-web \
-o jsonpath='{.status.secretFingerprint}{"\n"}'
kubectl -n storefront get secret storefront-web \
-o jsonpath='{.metadata.annotations.nauthera\.io/secret-fingerprint}{"\n"}'The same value means the Secret holds the secret the server verifies against. The operator makes the same comparison before it decides a resource has nothing left to do.
Reading it from the workload
The keys contain hyphens, so map them to the names your app expects rather than using
envFrom:
env:
- name: OIDC_ISSUER
valueFrom:
secretKeyRef: {name: storefront-web, key: issuer}
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef: {name: storefront-web, key: client-id}
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef: {name: storefront-web, key: client-secret}Until the operator has written the Secret, the container does not start, so an app
deployed together with its client simply waits for its credential. The issuer key gives
the app everything it needs for discovery at $OIDC_.
Environment variables are read once, when the container starts. If you would rather pick
up a rotated secret without a restart, mount the Secret as a volume: the kubelet updates
the files of a mounted Secret when it changes, though not through a subPath mount
(Kubernetes: Secrets). Have
the app read the file again, client-secret under the mount path, when the token endpoint
refuses the secret it holds.
Rotating a secret
To rotate, delete the Secret:
kubectl -n storefront delete secret storefront-webThe operator watches the Secrets its resources own. It sees this one go, finds no copy of the secret the server holds, and asks the server to mint a new one over it. The server replaces the stored digest, so the old secret stops working the moment the new one is saved. The operator writes the new Secret and records an event with both fingerprints:
kubectl -n storefront events --for oidcclient/storefront-webThe event's reason is SecretRotated, and its message says that workloads using the old
secret must reload it. There is no overlap window, so rotate when the app can restart, and
restart it with kubectl rollout restart if it reads the secret from its environment.
When delivery goes wrong
The secret exists only once, so the operator is careful not to drop it:
- The Secret write fails after the server minted the secret. The operator keeps the
secret in memory and retries the write with the same value, and the resource reports
SecretUndeliverable. If the operator restarts before the write succeeds, the copy is gone, and the next reconcile rotates instead, as above. - Another Secret already has the name. The operator never writes over a Secret it does
not own. The resource goes to phase
ErrorwithSecretUndeliverable, naming the Secret; remove it or pointspec.secret.secretNameelsewhere. - You rename
secretName. The operator copies the secret from the Secret it owns under the old name, so a rename does not rotate. - You delete the resource. Under the default
deletionPolicy: Retain, the client stays registered and the Secret is released: its owner reference is removed and it is annotatednauthera.with the resource's name. Underio/ retained-from Delete, the client is removed from the server and Kubernetes deletes the Secret with the resource.
Where the plaintext exists
Follow the secret end to end and it exists in four places: the server's memory during the call that minted it, the gRPC response to the operator over mutual TLS, the operator's memory until the Secret is written, and the Secret. It is never in Git, a CI log, a pipeline variable or a Terraform state file, and the server keeps only its digest.
That leaves the Secret, and Kubernetes' own caveats apply. Secrets are stored unencrypted in etcd unless the cluster enables encryption at rest, and anyone who may read Secrets or create Pods in the namespace can read this one (Kubernetes: Good practices for Secrets). Keep the client in the namespace of the app that uses it, and nowhere broader.
Or no shared secret at all
For a confidential client you control, private_key_jwt avoids the shared secret
entirely. The client signs an assertion with a key it holds and publishes its public keys
at jwksUri; the server stores only that URL:
spec:
# the rest of the spec as before, without secret.generate
clientId: storefront-web
tokenEndpointAuthMethod: private_key_jwt
jwksUri: https://shop.example.com/.well-known/jwks.json
tokenEndpointAuthSigningAlg: ES256Rotation then happens on the client's side, by publishing a new key. The server fetches
that URL, so it must be https and resolve to a public address: loopback, private and
link-local hosts are refused at dial time.
The OIDCClient reference states the secret rules, Deleting a resource both deletion policies, and the operator guide the rest of what the operator does with a manifest.