Argo CD Security Hardening: RBAC, SSO, and Repository Access Controls
Problem
Argo CD is a GitOps controller that continuously reconciles Kubernetes cluster state with a Git repository. Its privileged position — read access to Git repositories, write access to Kubernetes cluster state, and a web UI accessible to developers — makes it an attractive target.
Default and common Argo CD configurations have significant weaknesses:
- Default admin password. The initial admin password is set to the Argo CD server pod name, which is easily discoverable in any cluster. Teams that don’t change this expose a working admin credential.
- Admin account never disabled. After setting up SSO, the local admin account should be disabled. Many installations leave it active — a fallback that becomes a persistent vulnerability.
- RBAC grants developer group full access. A single RBAC policy giving all developers
role:admin(or omitting RBAC entirely) means any compromised developer credential can deploy malicious workloads to production. - Repository credentials stored as plaintext Kubernetes secrets. Git repository credentials (SSH keys, deploy tokens) stored in Kubernetes secrets are readable by anyone with
get secretsRBAC permission in theargocdnamespace. - cluster-admin ClusterRoleBinding. Argo CD’s application controller needs Kubernetes API access, but many installations bind
cluster-adminto the application controller service account. A compromise of the controller has unlimited cluster access. - No resource allowlist. Argo CD can deploy any Kubernetes resource by default — including ClusterRoleBindings, NetworkPolicies, PodSecurityContext overrides, and custom resources. Without a resource allowlist, a malicious commit to a tracked repository can escalate cluster permissions.
- Insecure API server exposure. The Argo CD API server is exposed via a LoadBalancer or Ingress without IP allowlisting. It is reachable from the internet.
Target systems: Argo CD 2.10+ (fine-grained RBAC, app-in-any-namespace); Helm and Kustomize application definitions; GitHub, GitLab, Bitbucket repositories; OIDC SSO (Okta, Azure AD, Google).
Threat Model
- Adversary 1 — Default admin credential: An attacker discovers the default admin password (the Argo CD server pod name, easily read from cluster events or CI logs) and logs into the Argo CD UI. They deploy a malicious workload or exfiltrate repository credentials.
- Adversary 2 — Overpermissive developer RBAC: A developer’s SSO credential is phished. The attacker logs into Argo CD with the developer’s identity and, because all developers have
role:admin, deploys a privileged container to production. - Adversary 3 — Repository credential exfiltration: An attacker with
get secretsaccess in theargocdnamespace reads the Kubernetes secret containing the Git deploy token. They clone all tracked repositories, including those containing application secrets embedded in manifests. - Adversary 4 — Malicious commit to tracked repository: An attacker (or a compromised CI pipeline with push access) commits a manifest containing a ClusterRoleBinding that grants cluster-admin to an attacker-controlled service account. Argo CD applies it automatically.
- Adversary 5 — Application controller service account abuse: The application controller’s service account has cluster-admin. An attacker who achieves code execution inside the controller pod (via dependency vulnerability or image tampering) has full cluster access.
- Access level: Adversaries 1 and 2 need valid Argo CD credentials. Adversary 3 needs RBAC access to list secrets. Adversary 4 needs push access to a tracked Git repository. Adversary 5 needs code execution in the controller pod.
- Objective: Deploy malicious workloads, exfiltrate secrets, escalate cluster privileges, gain persistent access.
- Blast radius: Argo CD admin access → deploy any workload to any cluster Argo CD manages → full cluster compromise of all managed clusters.
Configuration
Step 1: Change Default Admin Password and Disable After SSO
# Immediately change the default admin password after installation.
# The default password is the argocd-server pod name — discoverable in cluster events.
ARGOCD_SERVER=$(kubectl get pods -n argocd -l app.kubernetes.io/name=argocd-server -o name | head -1)
argocd login argocd.example.com --username admin --password "$ARGOCD_SERVER"
# Change to a strong random password.
NEW_PASSWORD=$(openssl rand -base64 32)
argocd account update-password --current-password "$ARGOCD_SERVER" --new-password "$NEW_PASSWORD"
# Store the new password in a secrets manager.
aws secretsmanager put-secret-value \
--secret-id argocd/admin-password \
--secret-string "$NEW_PASSWORD"
# After SSO is configured and tested, disable the local admin account.
argocd admin settings set --argocd-cm-name argocd-cm \
admin.enabled false
# Or patch argocd-cm directly:
kubectl patch configmap argocd-cm -n argocd \
--type merge \
-p '{"data": {"admin.enabled": "false"}}'
Step 2: Configure SSO with OIDC
Replace local accounts with OIDC SSO (Okta example):
# argocd-cm ConfigMap — OIDC configuration.
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
url: https://argocd.example.com
oidc.config: |
name: Okta
issuer: https://company.okta.com/oauth2/default
clientID: $oidc.clientID # References argocd-secret key.
clientSecret: $oidc.clientSecret # References argocd-secret key.
requestedScopes: ["openid", "profile", "email", "groups"]
requestedIDTokenClaims:
groups:
essential: true
# Map Okta groups to Argo CD RBAC. Groups used in argocd-rbac-cm.
groupsClaim: groups
# Disable self-signed cert for OIDC provider.
oidc.tls.insecure.skip.verify: "false"
# argocd-secret — store OIDC credentials.
apiVersion: v1
kind: Secret
metadata:
name: argocd-secret
namespace: argocd
type: Opaque
stringData:
oidc.clientID: "0oa1abc2defGHIJK3456"
oidc.clientSecret: "abcDEFghiJKL123mnoPQR456" # Rotate regularly.
Step 3: Fine-Grained RBAC
Argo CD RBAC uses p (policy) and g (group) statements. Map groups to scoped roles, not the built-in role:admin:
# argocd-rbac-cm ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-rbac-cm
namespace: argocd
data:
policy.default: role:readonly # Default: read-only for all authenticated users.
policy.csv: |
# Developers: sync only non-production apps; read all.
p, role:developer, applications, get, */*, allow
p, role:developer, applications, sync, dev/*, allow
p, role:developer, applications, sync, staging/*, allow
p, role:developer, logs, get, */*, allow
# Release engineers: sync production apps; cannot delete or create.
p, role:release-eng, applications, get, */*, allow
p, role:release-eng, applications, sync, */*, allow
p, role:release-eng, applications, action/*, */*, allow
# Platform team: full access excluding user management.
p, role:platform, applications, *, */*, allow
p, role:platform, clusters, *, *, allow
p, role:platform, repositories, *, *, allow
p, role:platform, projects, *, *, allow
p, role:platform, logs, get, */*, allow
p, role:platform, exec, create, */*, allow
# Security team: read-only access to all apps.
p, role:security, applications, get, */*, allow
p, role:security, clusters, get, *, allow
# Group → role mappings (from SSO groups claim).
g, okta-group:engineering, role:developer
g, okta-group:release-engineering, role:release-eng
g, okta-group:platform-engineering, role:platform
g, okta-group:security-engineering, role:security
scopes: "[groups]" # Which JWT claims to use for group lookup.
Key RBAC actions to restrict:
| Action | Who should have it | Risk if misgranted |
|---|---|---|
applications, delete, */* |
Platform team only | Anyone can delete production apps |
exec, create |
Platform/on-call only | Shell access in production containers |
clusters, create |
Platform team only | Add attacker-controlled cluster |
gpgkeys, create |
Platform team only | Add untrusted GPG key for commit verification |
repositories, create |
Platform team only | Add malicious repository to sync from |
Step 4: Application Controller Least Privilege
Restrict what the application controller can do in each managed cluster:
# Instead of cluster-admin, create a scoped ClusterRole.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: argocd-application-controller
rules:
# Core resources Argo CD needs to manage.
- apiGroups: [""]
resources: ["pods", "services", "endpoints", "persistentvolumeclaims",
"events", "configmaps", "serviceaccounts"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "daemonsets", "replicasets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Explicitly NOT included:
# - ClusterRoleBindings (prevents privilege escalation via malicious manifests)
# - Secrets (manage via External Secrets, not directly by Argo CD)
# - PodSecurityPolicies / PodSecurityAdmission overrides
For projects with sensitive resources, use AppProject resource allowlists:
# argocd AppProject — restrict what can be deployed.
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: payments-production
namespace: argocd
spec:
description: "Payments service — production"
sourceRepos:
- "https://github.com/company/payments-manifests" # Only this repo.
destinations:
- namespace: "payments"
server: "https://prod-cluster.k8s.example.com" # Only this cluster.
# Allowlist: only these resource types can be deployed.
clusterResourceWhitelist:
[] # No cluster-level resources (no ClusterRoles, etc.).
namespaceResourceWhitelist:
- group: "apps"
kind: "Deployment"
- group: "apps"
kind: "StatefulSet"
- group: ""
kind: "Service"
- group: ""
kind: "ConfigMap"
# Not included: ClusterRoleBinding, PodSecurityContext override, etc.
# Require GPG-signed commits for production.
signatureKeys:
- keyID: "ABC1234DEF5678" # GPG key fingerprint of release signing key.
Step 5: Repository Security
# Use SSH deploy keys, not personal tokens, for repository access.
# Generate a dedicated deploy key per repository.
ssh-keygen -t ed25519 -C "argocd-deploy-payments" \
-f /tmp/argocd-deploy-payments -N ""
# Add public key to GitHub as a read-only deploy key.
# Store private key in Kubernetes secret.
kubectl create secret generic argocd-repo-payments \
--namespace argocd \
--from-file=sshPrivateKey=/tmp/argocd-deploy-payments
# Shred the private key from local storage.
shred -u /tmp/argocd-deploy-payments /tmp/argocd-deploy-payments.pub
# Register repository with Argo CD using the secret.
apiVersion: v1
kind: Secret
metadata:
name: payments-repo
namespace: argocd
labels:
argocd.argoproj.io/secret-type: repository
type: Opaque
stringData:
type: git
url: git@github.com:company/payments-manifests.git
sshPrivateKey: |
-----BEGIN OPENSSH PRIVATE KEY-----
... # Reference from external-secrets, not hardcoded.
-----END OPENSSH PRIVATE KEY-----
Rotate deploy keys annually:
# Script to rotate all Argo CD repository credentials.
for repo in $(argocd repo list -o json | jq -r '.[].repo'); do
echo "Rotating key for: $repo"
# Generate new key, add to GitHub, update Kubernetes secret.
done
Step 6: API Server Access Restrictions
# Restrict Argo CD UI/API access to internal networks only.
# Ingress with IP allowlist.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: argocd-server
namespace: argocd
annotations:
nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,172.16.0.0/12"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
# Require client certificate for automated access (optional, belt-and-suspenders).
# nginx.ingress.kubernetes.io/auth-tls-secret: "argocd/argocd-client-ca"
spec:
ingressClassName: nginx
tls:
- hosts:
- argocd.internal.example.com
secretName: argocd-tls
rules:
- host: argocd.internal.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: argocd-server
port:
number: 443
Step 7: Audit Logging and Alerting
Argo CD emits events that should be monitored:
# argocd-cm — enable audit log.
data:
resource.customizations: |
# Log all application sync events with who triggered.
application.resourceTrackingMethod: annotation # Annotate managed resources.
# Query Argo CD audit log for sensitive operations.
# Argo CD writes to Kubernetes events — aggregate in your SIEM.
kubectl get events -n argocd --field-selector reason=ResourceUpdated \
--sort-by='.lastTimestamp' -o json | \
jq '.items[] | select(.message | contains("admin"))'
# Monitor via argocd notification controller.
# Alert on production syncs, RBAC changes, repository additions.
# argocd-notifications-cm — alert on production sync.
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-notifications-cm
namespace: argocd
data:
trigger.on-prod-sync: |
- when: app.metadata.labels["env"] == "production" && app.status.operationState.phase in ["Succeeded", "Failed"]
send: [prod-sync-slack]
template.prod-sync-slack: |
message: |
:arrow_right: Production sync: *{{.app.metadata.name}}* — {{.app.status.operationState.phase}}
Triggered by: {{.app.status.operationState.initiatedBy.username}}
Revision: {{.app.status.sync.revision}}
Step 8: Telemetry
argocd_app_sync_total{app, project, dest_cluster, phase} counter
argocd_app_health_status{app, project, health_status} gauge
argocd_cluster_api_resource_objects{cluster, group, kind} gauge
argocd_git_fetch_fail_total{repo} counter
argocd_app_reconcile_duration_seconds{app} histogram
Alert on:
argocd_app_sync_total{phase="Failed"}— a sync failed; may indicate a manifest error or a cluster access issue.argocd_git_fetch_fail_totalnon-zero — Argo CD cannot fetch from a repository; repository credential may be revoked.- Any production sync triggered by a non-release-engineer account — potential unauthorized deployment.
- Admin account login after SSO migration is complete — admin account should be disabled; any login is anomalous.
- RBAC policy change (argocd-rbac-cm ConfigMap modified) — immediate review required.
Expected Behaviour
| Signal | Default Argo CD | Hardened Argo CD |
|---|---|---|
| Default admin credential | Pod name is the password; immediately exploitable | Changed at install; disabled after SSO setup |
| Developer deploys to production | All developers have admin; no restriction | Developer role restricted to dev/staging namespaces |
| Malicious ClusterRoleBinding in commit | Applied automatically | AppProject resource allowlist rejects cluster-level resources |
| Repository credential exfiltration | Plaintext in Kubernetes secret; readable by any secret-reader | Managed by External Secrets; Argo CD controller only |
| Production sync without approval | Immediate auto-sync | Sync requires release-engineer role; alert fires on sync |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Disabled admin account | Eliminates default credential risk | No emergency fallback if SSO fails | Keep emergency admin password in secrets manager; test SSO before disabling |
| Fine-grained RBAC | Limits blast radius of credential compromise | More complex to maintain; groups must be kept accurate | Automate group membership via IdP; review RBAC quarterly |
| Resource allowlist per project | Prevents privilege escalation via malicious manifests | Must explicitly allow new resource types | Start with a broad allowlist and tighten; use Kyverno to enforce separately |
| SSH deploy keys per repo | Credential isolation (one key = one repo) | More keys to manage; rotation overhead | Automate rotation; use GitHub fine-grained tokens as an alternative |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| SSO provider unavailable; admin disabled | Nobody can log into Argo CD | Argo CD UI returns 401/403 | Re-enable admin temporarily via kubectl patch cm argocd-cm; restore SSO |
| RBAC misconfiguration locks out platform team | Platform team gets role:readonly unexpectedly |
Platform team cannot sync apps | Correct argocd-rbac-cm; reload with argocd admin settings rbac validate |
| Repository credential rotation breaks sync | Argo CD fails to fetch; apps out of sync | argocd_git_fetch_fail_total alert |
Update the repository secret with the new credential |
| Resource allowlist too restrictive | New resource type rejected; sync fails | Sync failure log; OutOfSync app status |
Add resource type to AppProject whitelist; review security implications |
| Application controller loses cluster access | All apps permanently OutOfSync; no sync possible |
Mass OutOfSync alert | Check ClusterRoleBinding for application controller service account; restore |