Securing F5 CIS BIG-IP Credentials Using the External Secrets Operator with HashiCorp Vault¶
Overview¶
F5 BIG-IP Container Ingress Services (CIS) authenticates to the BIG-IP system using a username and password supplied through a native Kubernetes Secret (f5-bigip-ctlr-login) mounted into the controller pod via --credentials-directory. By default this Secret is authored directly as a base64-encoded manifest, which means the plaintext BIG-IP credential must either exist in version control or be applied manually — an undesirable practice for any production environment.
This guide describes how to eliminate that requirement by storing the BIG-IP credential in HashiCorp Vault and using the External Secrets Operator (ESO) to synchronize it into the native Secret that CIS already expects. CIS requires no code changes and no CIS-specific configuration to work with ESO, it simply reads the same Secret object it always has. ESO is solely responsible for creating and refreshing that Secret from Vault.
At a high level, Vault holds the credential as the source of truth, ESO reads from Vault and materializes a native Kubernetes Secret, and CIS consumes that Secret exactly as before — unaware that Vault or ESO exist.
Validated Component Versions¶
This integration was validated with the following component versions:
| Component | Validated Version |
|---|---|
| Kubernetes | v1.31 (kubeadm) |
| F5 CIS | 2.20.3 (Helm chart f5-bigip-ctlr), Custom Resource (CRD) mode |
| AS3 | 3.56.0 |
| External Secrets Operator | 2.8.0 (CRDs served at external-secrets.io/v1) |
| HashiCorp Vault | 1.x (Helm chart vault), standalone mode with raft (persistent) storage |
| BIG-IP | 17.5.1.6, LTM provisioned |
Important
Vault storage
Deploy Vault with persistent storage (Integrated Storage / raft, or an external storage backend). A dev-mode Vault (server.dev.enabled=true) holds its configuration in memory only, restarting the Vault pod wipes the Kubernetes auth method, policies, roles, and any KV secrets, which breaks the ESO integration with 403 InvalidProviderConfig errors.
This failure was confirmed during validation. Dev mode is not a supported configuration for anything beyond a disposable demonstration.
Prerequisites¶
- A Kubernetes cluster (v1.13 or later) with Helm 3 installed.
- A BIG-IP system reachable from the cluster nodes on TCP/443, with AS3 3.13 or later installed.
- A dedicated BIG-IP partition for CIS (for example,
k8s-cis). Do not use/Common. - A BIG-IP user account for CIS with the Administrator role, created before CIS starts.
Note
Create the BIG-IP user first
Create the BIG-IP user that CIS will authenticate as before installing CIS or writing the credential to Vault. If CIS starts with no valid credential available, or if the account does not yet exist, recovery may require direct tmsh or iControl REST access to the BIG-IP to reset the account.
Follow these step-by-step instructions to configure the HashiCorp Vault integration with the External Secrets Operator for F5 CIS.
1. Create the BIG-IP User¶
Create the dedicated partition and the CIS service account on the BIG-IP:
tmsh create auth partition k8s-cis
tmsh create auth user cis-ctlr \
partition-access add { k8s-cis { role administrator } } \
password '**<strong-password>**'
Important
Password character Restrictions
Avoid special characters such as # in the password. During validation, a password containing # allowed HTTP Basic authentication to succeed (HTTP 200) but caused iControl REST token authentication to fail (HTTP 401, restjavad AuthnWorker: failed to login tmos/local). Because CIS uses token authentication, this failure mode is not apparent from a simple curl -u test against the BIG-IP.
2. Install HashiCorp Vault with Persistent Storage¶
Create the Vault Helm values file, vault-values.yaml:
server:
standalone:
enabled: true
config: |
ui = true
listener "tcp" {
tls_disable = 1
address = "[::]:8200"
cluster_address = "[::]:8201"
}
storage "raft" {
path = "/vault/data"
}
dataStorage:
enabled: true
size: 1Gi
storageClass: <your-storage-class>
Add the Helm repository and install Vault:
helm repo add hashicorp https://helm.releases.hashicorp.com
helm install vault hashicorp/vault -n vault --create-namespace -f vault-values.yaml
Initialize and unseal Vault, then store the BIG-IP credential in the KV v2 secrets engine:
kubectl exec -n vault vault-0 -- vault operator init
kubectl exec -n vault vault-0 -- vault operator unseal <unseal-key-1>
# repeat unseal with additional keys per your unseal threshold
kubectl exec -n vault vault-0 -- vault kv put secret/f5-cis \
username=cis-ctlr \
password='**<strong-password>**'
3. Configure the Vault Kubernetes Auth Method¶
Create a ServiceAccount and the RBAC binding that Vault needs to validate tokens via the Kubernetes TokenReview API:
apiVersion: v1
kind: ServiceAccount
metadata:
name: eso-vault-auth
namespace: eso
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: eso-vault-auth-delegator
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:auth-delegator
subjects:
- kind: ServiceAccount
name: eso-vault-auth
namespace: eso
Apply the manifest, enable the Kubernetes auth method, and configure it:
kubectl apply -f eso-vault-auth-sa.yaml
kubectl exec -n vault vault-0 -- vault auth enable kubernetes
kubectl exec -n vault vault-0 -- vault write auth/kubernetes/config \
token_reviewer_jwt="$(kubectl create token eso-vault-auth -n eso --duration=8760h)" \
kubernetes_host="https://<kubernetes-api-server>:6443" \
kubernetes_ca_cert=@/path/to/ca.crt
Restrict access to only the BIG-IP secret path, then bind a role to the ServiceAccount:
kubectl exec -n vault vault-0 -- vault policy write f5-cis-secret-policy - <<EOF
path "secret/data/f5-cis" {
capabilities = ["read"]
}
EOF
kubectl exec -n vault vault-0 -- vault write auth/kubernetes/role/f5-cis-vault-role \
bound_service_account_names=eso-vault-auth \
bound_service_account_namespaces=eso \
policies=f5-cis-secret-policy \
ttl=1h
Note
Validate the login before proceeding
Confirm the Vault login path works end to end before wiring up ESO:
kubectl run vault-auth-test --rm -it --image=curlimages/curl \
--overrides='{"spec":{"serviceAccountName":"eso-vault-auth"}}' -n eso -- sh
# inside the pod:
# TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
# curl -s -X POST http://vault.vault.svc:8200/v1/auth/kubernetes/login \
# -d "{\"role\": \"f5-cis-vault-role\", \"jwt\": \"$TOKEN\"}"
A successful response returns a Vault client token with "policies": ["default", "f5-cis-secret-policy"] and no 403.
4. Install the External Secrets Operator¶
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
-n external-secrets --create-namespace
Note
ESO is a Cluster-wide singleton
The ESO controller watches ExternalSecret and ClusterSecretStore objects across all namespaces. Install only one ESO release per cluster. Do not deploy a second controller for isolation purposes — two controllers reconciling the same custom resources will race and produce conflicting writes.
5. Create the ClusterSecretStore and ExternalSecret¶
Define the ClusterSecretStore that points ESO at Vault using the Kubernetes auth path configured in Step 3:
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: vault-bigip
spec:
provider:
vault:
server: "http://vault.vault.svc:8200"
path: secret
version: v2
auth:
kubernetes:
mountPath: kubernetes
role: f5-cis-vault-role
serviceAccountRef:
name: eso-vault-auth
namespace: eso
Define the ExternalSecret that materializes the native Secret CIS expects, f5-bigip-ctlr-login:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: f5-bigip-credentials-sync
namespace: kube-system
spec:
refreshInterval: "1m"
secretStoreRef:
name: vault-bigip
kind: ClusterSecretStore
target:
name: f5-bigip-ctlr-login
creationPolicy: Owner
data:
- secretKey: username
remoteRef:
key: f5-cis
property: username
- secretKey: password
remoteRef:
key: f5-cis
property: password
Apply both manifests and verify their status:
kubectl apply -f clustersecretstore.yaml
kubectl apply -f externalsecret.yaml
kubectl get clustersecretstore vault-bigip
kubectl get externalsecret -n kube-system f5-bigip-credentials-sync
Expect Status: Valid for the ClusterSecretStore, and STATUS: SecretSynced / READY: True for the ExternalSecret.
6. Install CIS to Consume the ESO-Managed Secret¶
Create the CIS Helm values file, cis-values.yaml:
namespace: kube-system
args:
bigip_url: <bigip-mgmt-ip>
bigip_partition: k8s-cis
custom_resource_mode: true
pool_member_type: nodeport
bigip_login_secret: true
version: 2.20.3
Add the F5 chart repository and install CIS:
helm repo add f5-stable https://f5networks.github.io/charts/stable
helm install cis f5-stable/f5-bigip-ctlr -n kube-system -f cis-values.yaml
No credential is referenced by name in the Helm values. CIS mounts whichever Secret named f5-bigip-ctlr-login exists in its namespace via --credentials-directory the Secret that ESO already created in Step 5.
Important
Multi contoller namespace scoping
By default, CIS watches VirtualServer, TransportServer, and Ingress resources in all namespaces (--namespace defaults to All). If more than one CIS controller runs in the cluster — for example, during a staged migration or a side-by-side upgrade test each instance must be scoped with an explicit --namespace=<ns> argument. Without this, two controllers can both reconcile the same custom resource into two different BIG-IP partitions, and BIG-IP will reject the second attempt with an AS3 422 error such as: 0107176c:3: Invalid Virtual Address, the IP address <address> already exists. This occurs because a given virtual-server IP address cannot be reused across partitions on the same route domain.
Validation¶
Verify the Secret¶
Confirm the Secret exists and is managed by ESO rather than a hand-authored manifest:
kubectl get secret f5-bigip-ctlr-login -n kube-system
kubectl get secret f5-bigip-ctlr-login -n kube-system -o yaml
Expect ownerReferences[].kind: ExternalSecret and the label reconcile.externalsecrets.io/managed: "true".
Verify CIS Connectivity¶
kubectl logs deploy/<cis-release-name>-f5-bigip-ctlr -n kube-system
Note
CIS DOES NOT LOG AN EXPLICIT AUTHENTICATION SUCCESS
CIS does not print an explicit “authentication succeeded” message. Look instead for AS3 success lines:
[AS3][BigIP] post resulted in SUCCESS or [AS3][POST] SUCCESS: code: 200 --- tenant:<partition> --- message: ....
A 401/403 response, or the controller pod entering CrashLoopBackOff shortly after startup, indicates a credential problem. Because iControl REST rejects unauthenticated or invalid-credential requests before evaluating the AS3 declaration body, an HTTP 200 AS3 response is reliable evidence that authentication succeeded.
Credential Chain-of-Custody Check (Optional)¶
To confirm that the credential Vault holds is the one mounted into the CIS pod, trace the value through each stage of the pipeline:
- A human or automation writes the secret into Vault (
vault kv put secret/f5-cis ...). Vault is the source of truth; ESO does not create anything in Vault. - ESO reads from Vault via the ClusterSecretStore, and creates or syncs a native Kubernetes Secret (
f5-bigip-ctlr-login). - CIS reads that native Secret, mounted as files via
--credentials-directory(for example,/tmp/creds/username,/tmp/creds/password). CIS never communicates with ESO or Vault directly.
Vault (source of truth)
│ ESO reads from Vault
▼
ESO → creates / syncs native K8s Secret
│ CIS reads the Secret (mounted file)
▼
CIS
Compare a hash of the password at each stage; all three should be identical:
# 1. Value in Vault
kubectl exec -n vault vault-0 -- vault kv get -field=password secret/f5-cis | sha256sum
# 2. Value in the native Secret ESO created
kubectl get secret f5-bigip-ctlr-login -n kube-system \
-o jsonpath='{.data.password}' | base64 -d | sha256sum
# 3. Value mounted inside the CIS pod
kubectl exec -n kube-system deploy/<cis-release-name>-f5-bigip-ctlr -- \
cat /tmp/creds/password | sha256sum
Demonstrate Credential Rotation¶
Update the credential in Vault; ESO propagates the change automatically within the ExternalSecret’s refreshInterval:
kubectl exec -n vault vault-0 -- vault kv put secret/f5-cis \
username=cis-ctlr password='<new-password>'
Warning
Token caching during rotation
CIS’s BIG-IP auth token has a limited TTL (20 minutes by default), and CIS reuses that token until it expires or is invalidated. It does not proactively re-authenticate the moment the mounted credential file changes.
If the BIG-IP-side password is rotated independently of Vault (so the two are briefly out of sync), CIS continues operating on its cached token until that token is invalidated, at which point it re-authenticates using whatever credential is currently mounted. A CIS pod restart always forces immediate re-authentication using the currently mounted credential.
Troubleshooting¶
| Symptom | Cause | Resolution |
|---|---|---|
| ClusterSecretStore shows InvalidProviderConfig / 403 | Vault was deployed in dev mode and its pod restarted, wiping the Kubernetes auth method, policy, role, and KV secret. | Redeploy Vault with persistent (raft or external) storage. See the Important note in Overview. |
| CIS pod CrashLoopBackOff shortly after startup; log shows 401 Maximum number of login attempts exceeded | The mounted credential does not match the current BIG-IP password (for example, during a rotation test, or a stale Vault value). | Confirm the credential chain of custody, correct the value in Vault, and restart the CIS pod. Repeated failed attempts can trigger a brief BIG-IP rate limit that clears on its own; it is not a permanent lockout. |
| AS3 422 Invalid Virtual Address … already exists | More than one CIS controller is reconciling the same custom resource into different BIG-IP partitions because neither is namespace-scoped. | Add an explicit –namespace=<own-namespace> argument to each CIS deployment so controllers watch only their own resources. |
| kubectl logs deploy/f5-bigip-ctlr returns NotFound | The Deployment name depends on the Helm release name used at install (a release named cis produces cis-f5-bigip-ctlr). | Run kubectl get deploy -n kube-system to find the actual Deployment name for your release. |
Production Hardening¶
- Use TLS between ESO and Vault (
tls_disable = 0with an appropriate CA bundle) rather than the plaintext listener shown in this guide. - Scope the Vault policy as narrowly as possible — read-only access to the single KV path used by CIS.
- Rotate the BIG-IP credential regularly using Vault’s versioned KV secrets engine, and restart the CIS pod (or wait for its BIG-IP token to expire) to force prompt pickup of the new value.
- Use a dedicated BIG-IP partition per CIS instance; never point CIS at /Common.