Skip to content
All articles

Delivering OIDC client secrets without storing them in Git

How spec.secret.generate works: the server mints the secret, the operator writes it into a Secret with its fingerprint, and deleting it rotates.

By Michael Beutler, FounderPublished

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

The 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: none) 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:

KeyValue
client-idThe client ID the server registered
client-secretThe secret
issuerThe issuer of the organization the client belongs to

It is labelled app.kubernetes.io/managed-by: nauthera-operator, carries an owner reference to the OIDCClient, and is annotated nauthera.io/secret-fingerprint. 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_ISSUER/.well-known/openid-configuration.

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-web

The 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-web

The 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 Error with SecretUndeliverable, naming the Secret; remove it or point spec.secret.secretName elsewhere.
  • 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 annotated nauthera.io/retained-from with the resource's name. Under 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: ES256

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

Hear when the operator ships

One email when the operator ships. No newsletter.

By joining, you agree to our Privacy Policy and Terms.