Flux and OpenBao: Secrets and Signatures

This post shows off two Flux integrations with OpenBao: SOPS decryption through workload identity and sovereign OCI artifact signing with Cosign.

GitOps helps us declare our desired workloads, but how do we deal with and manage secrets? Additionally, as our fleet grows, we also blend artifacts and configuration from many different sources. How do we trust what we are running?

OpenBao is an open source secrets and encryption platform under the OpenSSF. In this post we’ll integrate OpenBao with Flux in two ways:

  • kustomize-controller will decrypt SOPS-encrypted Secrets through OpenBao using workload identity, with no static BAO_TOKEN or VAULT_TOKEN to bootstrap
  • Cosign will sign OCI artifacts with a key held within OpenBao, producing signatures Flux can verify without any service outside your infrastructure

For both integrations, we’ll use two OpenBao features. The Transit secrets engine performs encrypt, decrypt, and sign operations without ever releasing the key material, acting as a Key Management System (KMS), and the Kubernetes and JWT auth methods let a workload trade its Kubernetes-issued ServiceAccount token for a short-lived OpenBao token, so no long-lived credential has to exist on either the OpenBao or Kubernetes side.

Configuring SOPS: GitOps secrets without a static token

Keeping encrypted secrets in Git solves most of the secret-distribution problem: the ciphertext goes through the same pull requests and the same reconciliation as the rest of your configuration. SOPS, a CNCF project, is the standard tool for this. It encrypts the values of a file with a data key, then asks a KMS to protect that data key; only the small data key ever travels to the KMS. OpenBao is one of many supported backends that SOPS can use, and many teams rely on Flux’s support for decrypting secrets with SOPS.

Bootstrapping secrets management is tricky though. The SOPS encryption key lives in OpenBao’s Transit engine, so Flux used to need a static BAO_TOKEN added in the cluster in order to request decryption. Teams installing Flux would need to provision this bootstrap secret before using GitOps to manage secrets. Starting with Flux v2.9, that token is optional: kustomize-controller can now directly authenticate to OpenBao with Kubernetes ServiceAccount tokens.

Step 1: Configure OpenBao for SOPS decryption

For our use-case, OpenBao needs a Transit key, a decrypt-only policy, and an auth role bound to the kube ServiceAccount Flux will use. Both the “Kubernetes” and “JWT” auth methods within OpenBao work for this; with Kubernetes auth and the flux-system/kustomize-controller ServiceAccount, the setup is:

bao secrets enable transit
bao write transit/keys/sops key_type=aes256-gcm96

bao policy write flux_sops_decrypt - <<EOF
path "transit/decrypt/sops" {
  capabilities = ["update"]
}
EOF

bao auth enable kubernetes
bao write auth/kubernetes/config \
  kubernetes_host=https://kubernetes.example.com:6443 \
  kubernetes_ca_cert=@ca.crt \
  token_reviewer_jwt=@reviewer.jwt

bao write auth/kubernetes/role/flux-system_kustomize-controller \
  bound_service_account_names=kustomize-controller \
  bound_service_account_namespaces=flux-system \
  audience=https://openbao.example.com:8200 \
  token_policies=flux_sops_decrypt \
  ttl=20m

The role name here follows Flux’s {namespace}_{name} convention, and the audience needs to match the OpenBao address that will show up in the SOPS metadata below. Note that flux_sops_decrypt only grants decryption; the developer or CI identity encrypting in Step 2 needs update on transit/encrypt/sops instead. The Kubernetes auth and JWT/OIDC auth API docs cover the OpenBao server-side options.

Step 2: Encrypt a Secret with OpenBao

After authenticating to OpenBao, a developer or CI job encrypts a regular Kubernetes Secret with the Transit key:

sops encrypt \
  --hc-vault-transit https://openbao.example.com:8200/v1/transit/keys/sops \
  --encrypted-regex '^(data|stringData)$' \
  secret.yaml > secret.enc.yaml
# These flag options can be defaulted in the repo with `.sops.yaml`

SOPS calls transit/encrypt/sops on the user’s behalf and writes out a file that is encrypted and safe to commit to Git:

apiVersion: v1
kind: Secret
metadata:
  name: database
  namespace: apps
type: Opaque
stringData:
  username: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
  password: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
sops:
  hc_vault:
    - vault_address: https://openbao.example.com:8200
      engine_path: transit
      key_name: sops
      created_at: "2026-07-14T12:00:00Z"
      enc: vault:v1:...
  encrypted_regex: ^(data|stringData)$
  mac: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]

The plaintext values are replaced with ciphertext, and the sops.hc_vault metadata records which OpenBao instance and key can decrypt them.

Step 3: Configure kustomize-controller to use workload identity for OpenBao

When Flux reconciles a file like this, kustomize-controller must ask OpenBao to decrypt it. Instead of a static token, the controller gets an allowlist of OpenBao instances and their JWT-backed login endpoints from a ConfigMap in the flux-system namespace, set by the --sops-vault-configmap controller flag:

# data fields for a ConfigMap in the flux-system namespace
data:
  config.yaml: |
    instances:
      - address: https://openbao.example.com:8200
        loginPath: auth/kubernetes/login    

During decryption, the OpenBao address in the SOPS metadata matches an entry from this list. kustomize-controller requests a ServiceAccount token with that address as its audience and presents it at the login path, where OpenBao matches it against the role from Step 1 and returns a short-lived token. Flux uses that token to call the Transit engine and decrypt the Secret. No static credential is needed anywhere for this decryption operation! We’re leveraging Kubernetes’ native workload identity.

Step 4: Use a dedicated ServiceAccount for decryption

By default the exchange above uses kustomize-controller’s own ServiceAccount. A Flux Kustomization can select a dedicated one instead:

spec:
  decryption:
    provider: sops
    serviceAccountName: sops

This requires the ObjectLevelWorkloadIdentity feature gate to be enabled in kustomize-controller. For a Kustomization in the apps namespace, the sops ServiceAccount needs a matching apps_sops role in OpenBao, configured with the same audience and policy as in Step 1. For more details, see the Kustomization decryption documentation.

Cosign: Sovereign Software Signatures

We can use this KMS setup for more than application Secrets.

Flux can verify the Cosign signature of an OCI artifact before reconciling desired state from it. With a static public key, that verification can be entirely local to the cluster: Flux checks the signature against the key and forms no opinion about how or where the artifact was signed. It never contacts Fulcio, Rekor, or any other service outside the cluster and its container registry.

This makes a fully self-hosted signing chain possible: OpenBao holds the signing key, Cosign signs the artifact, the registry stores the signature, and Flux verifies it. No public cloud KMS ever holds your key, and no public transparency log records your signing events. This is really attractive in regulated environments that prioritize data-sovereignty.

OpenBao Transit engine   →   Cosign   →   GHCR (artifact + signature)   →   Flux
holds the signing key        signs        stores signature                  verifies

Flux also supports keyless verification: omit .verify.secretRef and Flux matches OIDC identities against Fulcio certificates and the Rekor transparency log. Keyless signing gives you short-lived signing identities and a public audit record, at the cost of depending on public Sigstore infrastructure. Flux lets you choose this behavior per source; we use a static key in this post to show that key custody and rotation can stay fully under your responsibility.

For a fully reproducible setup, see the openbao-flux-demo on GitHub.
We’ll detail the steps quickly below.

Step 1: Generate the signing key in OpenBao

OpenBao’s Transit secrets engine is a cryptography-as-a-service backend: it holds key material and performs sign and verify operations on request. Cosign supports this natively. Let’s use Cosign to generate the keypair through our openbao:// KMS URI:

cosign generate-key-pair --kms openbao://control-plane-demo

The private key is created inside OpenBao and never written to disk. Only cosign.pub lands on the local filesystem; we’ll configure Flux to use this public key.

The demo runs OpenBao in development mode with a root token to keep setup short. In production, you’ll want to sign with a token scoped to just this key like so:

path "transit/keys/control-plane-demo" {
  capabilities = ["read"]
}

path "transit/sign/control-plane-demo" {
  capabilities = ["update"]
}

Step 2: Push and sign the artifact by digest

Package the manifests as an OCI artifact and push them with the Flux CLI:

flux push artifact oci://ghcr.io/controlplaneio-openbao/openbao-flux-demo-workload:latest \
  --path=./workload \
  --source="https://github.com/controlplaneio-openbao/openbao-flux-demo" \
  --revision="main@sha1:$(git rev-parse HEAD)"

Then sign the pushed artifact by its digest. Passing --tlog-upload=false keeps the signature off the public Rekor log, so the signing event itself never becomes a public record:

cosign sign --key openbao://control-plane-demo --tlog-upload=false \
  ghcr.io/controlplaneio-openbao/openbao-flux-demo-workload@sha256:...

OpenBao performs the signing operation and the signature is stored in the registry alongside the artifact. One thing to watch out for: Cosign authenticates to the registry on its own and does not share credentials with flux push artifact --creds, so it needs a separate cosign login.

Step 3: Configure Flux to verify the artifact

Flux only needs the public key. Create a Secret holding it and reference that Secret from the OCIRepository’s .spec.verify. The demo creates the Secret directly; in production you would commit the public key to Git (it is not sensitive, but you can optionally encrypt it), then let Flux manage the Secret alongside your other manifests:

kubectl -n flux-system create secret generic cosign-public-key \
  --from-file=cosign.pub=cosign.pub
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
  name: demo-workload
  namespace: flux-system
spec:
  interval: 5m
  url: oci://ghcr.io/controlplaneio-openbao/openbao-flux-demo-workload
  ref:
    tag: latest
  verify:
    provider: cosign
    secretRef:
      name: cosign-public-key

When the signature checks out, Flux records it on the source and only then applies the manifests:

SourceVerified=True (Succeeded): verified signature of revision latest@sha256:…

The whole sign & verify flow here only depends on our private infrastructure. No external services on the internet are needed, the signing key comes from our OpenBao instance, and the signature on the artifact proves that the desired state configuration was signed within our network.

Get Involved

We’re really keen on helping folks adopt workload identity. In this demo Flux authenticates to OpenBao with short-lived ServiceAccount tokens instead of a bootstrap secret, and clusters only reconcile artifacts signed by secure and self-hosted key infrastructure.

If you run OpenBao and Flux together, we’d like to hear how it goes. Talk to us in the #flux channel on CNCF Slack, or bring your use-case to one of the meetings on our community page.

References