CoursesArgo CDHardening Argo CD

Hardening Argo CD

It can deploy anything, anywhere.

Advanced14 min · lesson 11 of 12

Argo CD is the master locksmith of your platform. It keeps a copy of the key to every cluster it manages, and it faithfully does whatever the work order in Git tells it to do. That combination is what makes it useful, and it is also what makes it dangerous. Steal the key ring and you own every cluster. Forge a convincing work order and Argo CD applies it for you, with its own broad permissions, no questions asked. Hardening it means protecting two different things: the locksmith itself (the Argo CD control plane and the credentials it holds) and the work orders (the Git repositories it trusts).

Argo CD is a handful of pods that each do one job. There is argocd-server (the application programming interface and web login page), argocd-repo-server (which clones your Git repos and renders the raw Kubernetes manifests), argocd-application-controller (which compares what Git says to what the cluster runs, and applies the difference), argocd-redis (a cache), and argocd-dex-server (an optional login broker for single sign-on, meaning one login shared across many apps). Each one is a door. A serious hardening pass walks every door, checks who can open it, and asks what is on the other side.

See How Much Power It Already Has

Before you lock anything down, look at what Argo CD can reach today. It manages other clusters using credentials stored as Kubernetes Secrets in its own namespace. List them.

terminal
kubectl -n argocd get secrets -l argocd.argoproj.io/secret-type=cluster
output
NAME TYPE DATA AGE
cluster-prod-a Opaque 3 41d
cluster-prod-b Opaque 3 41d
cluster-staging Opaque 3 67d

Each of those secrets holds a bearer token or client certificate for a whole cluster, and in most installs that credential is bound to cluster-admin. Read one and you can talk to that cluster as god. The local cluster is even simpler. Argo CD manages it through its own service account, and the default install grants that account everything.

terminal
kubectl get clusterrole argocd-application-controller -o json | jq '.rules'
output
[
{
"verbs": [
"*"
],
"apiGroups": [
"*"
],
"resources": [
"*"
]
},
{
"verbs": [
"get"
],
"nonResourceURLs": [
"*"
]
}
]

That is every verb, on every resource, in every API group. It has to be broad, because Argo CD is supposed to deploy arbitrary things. So you cannot fix this by shrinking the ClusterRole (a set of permissions attached to the whole cluster) without breaking Argo CD's job. The fence goes somewhere else: at the Argo CD access layer (who can tell it to act) and at the cluster's admission layer (what it is allowed to apply). Keep that division in mind for the rest of this lesson.

Shut the Doors You Are Not Using

Every fresh Argo CD install ships with a local admin account and a generated password, like a spare key left under the doormat during construction. It is fine for bootstrapping. It becomes a liability once real users log in through single sign-on, because it is a standing password that lives outside your identity provider, is not covered by multi-factor authentication, and shows up in logs as a faceless 'admin'. Find the bootstrap password first, then get rid of the account.

terminal
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath='{.data.password}' | base64 -d; echo
output
aX9k2PqmNvR4tLb7

Once your team can log in through single sign-on and role-based access control is working, disable the local admin and confirm anonymous access is off in one change to the argocd-cm ConfigMap (the ConfigMap is Argo CD's main settings file).

terminal
kubectl -n argocd patch configmap argocd-cm --type merge \
-p '{"data":{"admin.enabled":"false","users.anonymous.enabled":"false"}}'
output
configmap/argocd-cm patched

Setting admin.enabled to false kills the local password login. Setting users.anonymous.enabled to false makes sure nobody can reach the API or UI without logging in (anonymous access is off by default, but confirm it, because a well-meaning demo config sometimes turns it on). While you are in there, make sure argocd-server runs with TLS (Transport Layer Security, the encryption behind HTTPS) and not the --insecure flag, so logins and tokens are never sent in the clear.

Disabling admin can lock you out
If you turn off the admin account and your single sign-on then breaks (an expired identity-provider secret, a bad OIDC (OpenID Connect, a standard login protocol) config, a Dex crash), nobody can log in through the UI. Keep a break-glass path. You can always re-enable admin by patching admin.enabled back to true in argocd-cm and restarting argocd-server, because whoever holds kubectl access to the namespace outranks the web login. Test your SSO login and your break-glass step before you trust the lockdown.

RBAC: Deny Everyone, Then Hand Out Keys

Treat role-based access control (RBAC) like key cards for an office. The safe default is that a new card opens nothing, and you add doors deliberately. Argo CD's access rules live in the argocd-rbac-cm ConfigMap. The single most important line is policy.default, the access a logged-in user gets before any rule matches. Leave it empty. That means 'authenticated, but allowed to do nothing until a rule says otherwise'.

/argocd-rbac-cm.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-rbac-cm
namespace: argocd
data:
policy.default: ''
scopes: '[groups]'
policy.csv: |
# can sync and read ONLY the prod-web project's apps
p, role:web-deployer, applications, get, prod-web/*, allow
p, role:web-deployer, applications, sync, prod-web/*, allow
# map an identity-provider group onto that role
g, acme:platform-oncall, role:web-deployer

Read the p lines as 'this role may do this action on this object'. The g line grafts an outside group onto a role, so membership is managed in your identity provider, not in Argo CD. Setting scopes to '[groups]' tells Argo CD which claim from the login token carries group membership. Notice what the on-call role cannot do: it cannot delete applications, cannot touch clusters or repositories, and cannot see anything outside the prod-web project. Grant the narrow verb (sync), never the wide one (*).

You do not have to guess whether a rule works. Argo CD can answer the exact question 'can this subject do this thing' from the command line, reading the live config in the cluster.

terminal
argocd admin settings rbac can role:web-deployer sync applications 'prod-web/checkout'
argocd admin settings rbac can role:web-deployer delete applications 'prod-web/checkout'
output
Yes
No

Yes to sync, No to delete. That is your access model, proven, without waiting for someone to try it in anger.

Projects Are the Blast-Radius Fence

A ConfigMap decides who may act. An AppProject (Argo CD's own boundary object, usually called a project) decides what an action is even allowed to touch. Projects are the fence around blast radius, meaning how far the damage spreads when one thing goes wrong. The trap is the built-in project named default, which every new install ships with and which trusts everything.

terminal
kubectl -n argocd get appproject default -o yaml | grep -A8 '^spec:'
output
spec:
clusterResourceWhitelist:
- group: '*'
kind: '*'
destinations:
- namespace: '*'
server: '*'
sourceRepos:
- '*'

Read that plainly. An application in the default project may pull from any Git repo, deploy to any cluster and any namespace, and create any cluster-scoped resource, including cluster roles and admission webhooks. Never put real workloads there. Make a project per blast radius and pin every dimension.

/appproject-prod-web.yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: prod-web
namespace: argocd
spec:
description: Production web tier
sourceRepos:
- https://github.com/acme/prod-web-manifests.git
destinations:
- server: https://prod-a.k8s.acme.internal
namespace: web
clusterResourceWhitelist: [] # forbid ALL cluster-scoped resources
namespaceResourceBlacklist:
- group: rbac.authorization.k8s.io
kind: RoleBinding
signatureKeys:
- keyID: 4AEE18F83AFDEB23 # require commits signed by this key

Every field is a wall. sourceRepos allows exactly one repository, so a stolen token for some other repo cannot be aimed at this project. destinations allows one cluster and one namespace, so a manifest that says namespace: kube-system is rejected at sync time. An empty clusterResourceWhitelist means this project cannot create a single cluster-scoped object, which is where most privilege-escalation manifests live. namespaceResourceBlacklist then blocks specific dangerous kinds even inside the allowed namespace. signatureKeys is the strongest wall of all, and it deserves its own section.

Git Is Inside Your Perimeter Now

Here is the shift that trips people up. In GitOps (running your infrastructure from a Git repository as the single source of truth), merging to a synced branch is a deploy. There is no separate button. Whoever can push to that branch can change production. The repository is now part of your production, so it needs production controls: branch protection so nobody pushes straight to main, required reviews so a second person signs off, and CODEOWNERS (a file that assigns required reviewers to specific paths) so changes to sensitive folders pull in the right approver.

Reviews stop honest mistakes. They do not stop a stolen developer laptop or a compromised continuous-integration bot that can push commits. Signed commits do. A signed commit carries a cryptographic signature made with a key only the real author holds, using GnuPG (GNU Privacy Guard, the standard tool for signing and verifying data). Set signatureKeys on the project, import the matching public keys into Argo CD, and the application-controller refuses to sync any revision that is not signed by a listed key. A defender sees the refusal directly on the Application.

terminal
kubectl -n argocd get application prod-web-checkout \
-o jsonpath='{.status.conditions[*].message}'; echo
output
Target revision e3f9a1c is not signed, but signature verification is required for project prod-web

That condition does two jobs. It stops the sync, and it also raises a flag. If it fires when your team did nothing unusual, someone pushed an unsigned commit to a protected branch, and you want to know why right now.

The repo-server runs your tooling
argocd-repo-server does real work to turn your source into manifests. It runs Helm, Kustomize, and any config management plugin you enable. That is genuine code execution inside Argo CD's trust boundary. A booby-trapped Helm chart, a Kustomize build that pulls a remote base, or an over-powered custom plugin can run commands in that pod, which sits right next to your cluster credentials. Keep plugins to a minimum, avoid --load-restrictor LoadRestrictionsNone and remote bases from repos you do not control, and give the repo-server its own restrictive NetworkPolicy (a firewall rule that limits which network connections a pod may open) so a foothold there cannot phone home.
Where a bad change gets stopped
1Push / open PR
branch protection blocks direct pushes
2Review + merge
required reviewers, CODEOWNERS on sensitive paths
3Argo CD reads revision
signature verified against project keys
4Project scope check
repo, cluster, namespace, resource-kind allowlist
5Admission control
OPA Gatekeeper or Kyverno, an independent policy gate
6Running workload
drift detection + audit log of every sync

No single wall in that line is trusted to hold alone. Branch protection can be misconfigured, a signing key can leak, a project scope can be set too loose. That is why admission control sits at the end as an independent gate. Two common ones are OPA Gatekeeper (Gatekeeper wraps the Open Policy Agent, a general-purpose policy engine) and Kyverno (a policy engine built specifically for Kubernetes). Neither one cares whether Argo CD, a human, or an attacker sent the manifest. Even a perfectly signed, in-scope change still has to satisfy policy before it runs.

Quick check
01Your production apps run in Argo CD's built-in default project, which sets sourceRepos, destinations, and clusterResourceWhitelist all to '*'. An attacker steals a token that can push to one of your manifest repos. What is the most direct risk?
Incorrect — The controller's wide-open ClusterRole is exactly what makes the pushed manifest powerful; Git is only the trigger.
Correct — with no repo, destination, or cluster-resource limits, a pushed manifest can grant cluster-scoped privilege and deploy anywhere.
Incorrect — The default project pins nothing, so there is no isolation to fall back on.
Incorrect — Auto-sync or a normal sync applies the change; Argo CD has no built-in human approval gate.
02The default install gives the argocd-application-controller a ClusterRole that grants every verb on every resource in every API group. Why does the lesson argue against hardening Argo CD by trimming that ClusterRole down to a smaller permission set?
Incorrect — The controller genuinely uses that ClusterRole against the local cluster; it is not a dead setting.
Incorrect — It grants every verb, including create and delete, not just reads, so it is anything but harmless.
Correct — Because it must be able to apply anything, you fence who can invoke it and what the cluster will admit, not the ClusterRole itself.
Incorrect — Nothing stops you editing it; the problem is that a smaller role would stop Argo CD from doing its job.
03You set admin.enabled to false and confirmed anonymous access is off. A week later your identity provider's client secret expires, argocd-dex-server crash-loops, and nobody on the team can log in through the web interface. Which recovery matches the lesson's break-glass guidance?
Incorrect — That exposes the API and UI to unauthenticated access, a worse problem, and is not the break-glass path.
Correct — Whoever holds kubectl on the argocd namespace can re-enable the local admin and log in, which is exactly the break-glass path.
Incorrect — kubectl access to the namespace is precisely what lets you recover; you do not have to wait.
Incorrect — That is destructive and unnecessary; simply re-enabling the local admin restores access.

The last habit worth building is proving each change instead of assuming it. Ask Argo CD to show you its own accounts, and confirm the door you closed reads as closed.

terminal
argocd account list
output
NAME ENABLED CAPABILITIES
admin false login

admin reads ENABLED false. Pair that with a Yes/No from argocd admin settings rbac can, a kubectl -n argocd get appproject that shows real limits instead of '*', and a signature-required condition on your Applications, and you have four independent checks that your locksmith only opens the doors you meant to open.

Try this

Run kubectl -n argocd get secrets -l argocd.argoproj.io/secret-type=cluster on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: disabling admin can lock you out. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related