Debugging ExternalSecrets
12 Feb 2026Debugging ExternalSecrets: When ConfigMaps Override Azure Key Vault Secrets in Kubernetes
Recently, I encountered a subtle but important issue in our Kubernetes deployment: secrets from Azure Key Vault weren’t being injected into our application, despite ExternalSecrets being configured correctly. The culprit? ConfigMap values were silently overriding the secrets due to duplicate key names.
This post explores the issue, explains the underlying Kubernetes behavior, and shares best practices to avoid similar problems.
The Problem
In our healthequity-middleware Dev environment:
- ✅ ExternalSecrets Operator was enabled and configured
- ✅ Azure Key Vault integration was working
- ✅ Kubernetes Secrets were being created successfully
- ❌ The application was receiving hardcoded values instead of AKV secrets
The secrets appeared to be configured correctly, but the application never saw them. This was a silent failure—no errors, no warnings, just wrong values.
Understanding Kubernetes ConfigMaps and Secrets
Before diving into the issue, let’s review the basics.
ConfigMaps: Non-Sensitive Configuration
ConfigMaps store configuration data as key-value pairs:
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
DATABASE_HOST: "postgres.example.com"
LOG_LEVEL: "info"
API_TIMEOUT: "30s"
Secrets: Sensitive Data
Secrets store sensitive information (base64 encoded):
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
data:
DATABASE_PASSWORD: "cGFzc3dvcmQxMjM="
API_KEY: "YWJjZGVmZ2hpamts"
Injecting as Environment Variables
Both can be injected into pods using envFrom:
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
image: myapp:latest
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secrets
The Precedence Problem
Here’s where things get tricky. When the same key exists in both a ConfigMap and a Secret, order matters:
envFrom:
- configMapRef:
name: app-config # Has API_KEY: "dev-key"
- secretRef:
name: app-secrets # Has API_KEY: "prod-secret-key"
Result: The last source wins. In this case, API_KEY gets the value from the Secret.
But if you reverse the order:
envFrom:
- secretRef:
name: app-secrets # Has API_KEY: "prod-secret-key"
- configMapRef:
name: app-config # Has API_KEY: "dev-key"
Result: API_KEY gets the value from the ConfigMap, completely overriding the secret!
This behavior is by design in Kubernetes, but it’s easy to overlook when managing large configurations.
Enter ExternalSecrets Operator
ExternalSecrets Operator is a powerful tool that syncs secrets from external sources (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, etc.) into Kubernetes Secrets.
How It Works
flowchart LR
AKV[Azure Key Vault]
SS[SecretStore]
ES[ExternalSecret]
K8S[Kubernetes Secret]
POD[Pod]
SS -->|authenticates with| AKV
ES -->|references| SS
ES -->|pulls secrets from| AKV
ES -->|creates/syncs| K8S
K8S -->|injected as env vars| POD
Step 1: Create a SecretStore
This defines how to authenticate with your secret provider:
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: azure-secretstore
spec:
provider:
azurekv:
authType: WorkloadIdentity
vaultUrl: https://kv-example.vault.azure.net/
Step 2: Create an ExternalSecret
This defines which secrets to sync:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: app-externalsecret
spec:
secretStoreRef:
name: azure-secretstore
target:
name: app-secrets
data:
- secretKey: API_KEY
remoteRef:
key: api-key # Key name in Azure Key Vault
The ExternalSecrets Operator reads this configuration, fetches secrets from Azure Key Vault, and creates/updates a standard Kubernetes Secret.
Important: The resulting Kubernetes Secret behaves exactly like any manually-created secret, including the precedence rules we discussed earlier.
Our Specific Issue
Here’s what our Dev environment configuration looked like:
ExternalSecret Configuration:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: healthequity-middleware-externalsecret
spec:
# ... secretStore reference ...
data:
- secretKey: MFA_API_CLIENT_ID
remoteRef:
key: mfa-api-client-id
- secretKey: MFA_API_CLIENT_SECRET
remoteRef:
key: mfa-api-client-secret
- secretKey: HEALTH_EQUITY_API_CLIENT_ID
remoteRef:
key: he-api-client-id
# ... more secrets
ConfigMap Configuration (in dev-eus.yaml):
configMap:
enabled: true
data:
# URLs and endpoints - fine
MFA_API_TOKEN_ENDPOINT: http://stub:3000/oauth2/token
MFA_API_SCOPE: read:login...
# ⚠️ Problem: Duplicate keys!
MFA_API_CLIENT_ID: mfa-client
MFA_API_CLIENT_SECRET: integration-service-secret-key-dev
HEALTH_EQUITY_API_CLIENT_ID: member-api-client
HEALTH_EQUITY_API_CLIENT_SECRET: health-equity-secret
# ...
What Happened
- ExternalSecrets Operator successfully created a Kubernetes Secret with values from Azure Key Vault
- Our Helm chart mounted both the Secret and ConfigMap as environment variables
- The ConfigMap values were processed last (or took precedence), overriding the AKV secrets
- Application received hardcoded ConfigMap values instead of real secrets
Why It Went Unnoticed
This issue was particularly sneaky because:
- No errors were thrown—both sources provided valid values
- The Dev environment intentionally used stub services with test credentials
- The hardcoded values “worked” for development purposes
- The problem only surfaced when we tried to switch to real AKV secrets
How Our Staging Environment Got It Right
Looking at our Staging configuration revealed the solution:
configMap:
data:
# ✅ Only non-sensitive configuration
MFA_API_TOKEN_ENDPOINT: https://login.microsoftonline.com/...
MFA_API_SCOPE: api://xxx/.default
HEALTH_EQUITY_API_HOST: https://api.stg.example.com
# ✅ NO secrets here!
# CLIENT_ID and CLIENT_SECRET come from ExternalSecret
The secrets came exclusively from ExternalSecrets, with no key overlap in the ConfigMap.
Best Practices
1. Maintain Clear Separation of Concerns
- ConfigMaps: Non-sensitive configuration (URLs, timeouts, feature flags, API scopes)
- Secrets: Sensitive data only (passwords, API keys, tokens, certificates)
2. Never Duplicate Keys
Each environment variable should have exactly one source. If a key exists in a Secret, it should not exist in a ConfigMap.
Use a naming convention to make this obvious:
# Good
configMap:
API_BASE_URL: "https://api.example.com"
API_TIMEOUT: "30s"
secret:
API_KEY: "secret-value"
API_SECRET: "another-secret"
3. Structure Your Helm Values Clearly
app:
# Non-sensitive configuration
config:
apiUrl: "https://api.example.com"
timeout: "30s"
# Secrets - reference existing ones from ExternalSecrets
existingSecrets:
- app-externalsecret
4. Environment-Specific Strategies
Production/Staging:
- Always use external secret management (ExternalSecrets + AKV/AWS)
- ConfigMaps for non-sensitive config only
- No hardcoded secrets, ever
Development:
- Option A: Use ExternalSecrets with a dev-specific Key Vault (recommended for consistency)
- Option B: Use plain Kubernetes Secrets with test values (simpler for local development)
- Option C: Use ConfigMap with test values only if absolutely necessary (least secure)
5. Review Your envFrom Order
If you must have overlapping keys (though I don’t recommend it), be explicit:
envFrom:
# Secrets should come last to take precedence
- configMapRef:
name: app-config
- secretRef:
name: app-secrets # This wins on conflicts
6. Add Validation
Consider adding an init container to validate critical secrets:
initContainers:
- name: validate-secrets
image: busybox
command:
- sh
- -c
- |
if [ -z "$API_KEY" ]; then
echo "ERROR: API_KEY not set"
exit 1
fi
envFrom:
- secretRef:
name: app-secrets
Debugging Commands
When troubleshooting similar issues:
# Check ExternalSecret sync status
kubectl get externalsecret -n <namespace>
kubectl describe externalsecret <name> -n <namespace>
# Verify the created Secret exists and has data
kubectl get secret <secret-name> -n <namespace> -o yaml
# Check what environment variables the pod actually sees
kubectl exec <pod-name> -n <namespace> -- env | sort
# View ExternalSecrets Operator logs
kubectl logs -n external-secrets-system \
deployment/external-secrets-operator
The Fix
For our issue, the solution was straightforward:
Remove all sensitive keys from the Dev ConfigMap:
# Before
configMap:
data:
MFA_API_CLIENT_ID: mfa-client # ❌ Remove
MFA_API_CLIENT_SECRET: xxx # ❌ Remove
MFA_API_TOKEN_ENDPOINT: http://... # ✅ Keep
# After
configMap:
data:
MFA_API_TOKEN_ENDPOINT: http://... # ✅ Keep
MFA_API_SCOPE: read:login... # ✅ Keep
# All secrets now come from ExternalSecret only
Key Takeaways
- ConfigMaps and Secrets can conflict when they contain the same environment variable names
- Order matters in
envFrom—later sources override earlier ones - ExternalSecrets creates standard Kubernetes Secrets, so all normal precedence rules apply
- Separation of concerns prevents issues—keep secrets in Secrets, config in ConfigMaps
- Silent failures are dangerous—always validate that your environment variables match expectations
- Compare your environments—if one works and another doesn’t, diff the configurations
Conclusion
This issue highlights the importance of understanding Kubernetes’ environment variable precedence rules, especially when using tools like ExternalSecrets Operator. While the operator simplifies secret management, it doesn’t change the fundamental behavior of how Kubernetes injects environment variables.
The fix is simple once you understand the problem: maintain strict separation between configuration and secrets, never duplicate keys, and always validate your environment variables in each environment.
Have you encountered similar issues with Kubernetes ConfigMaps and Secrets? I’d love to hear about your experiences in the comments!
Additional Resources: