Argo CD Security Hardening: RBAC, SSO, and Repository Access Controls

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 secrets RBAC permission in the argocd namespace.
  • cluster-admin ClusterRoleBinding. Argo CD’s application controller needs Kubernetes API access, but many installations bind cluster-admin to 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 secrets access in the argocd namespace 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_total non-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