Flux and OpenBao: Secrets and Signatures
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_TOKENorVAULT_TOKENto 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.