Hardening Argo CD
It can deploy anything, anywhere.
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.
kubectl -n argocd get secrets -l argocd.argoproj.io/secret-type=cluster
NAME TYPE DATA AGEcluster-prod-a Opaque 3 41dcluster-prod-b Opaque 3 41dcluster-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.
kubectl get clusterrole argocd-application-controller -o json | jq '.rules'
[{"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.
kubectl -n argocd get secret argocd-initial-admin-secret \-o jsonpath='{.data.password}' | base64 -d; echo
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).
kubectl -n argocd patch configmap argocd-cm --type merge \-p '{"data":{"admin.enabled":"false","users.anonymous.enabled":"false"}}'
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.
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'.
apiVersion: v1kind: ConfigMapmetadata:name: argocd-rbac-cmnamespace: argocddata:policy.default: ''scopes: '[groups]'policy.csv: |# can sync and read ONLY the prod-web project's appsp, role:web-deployer, applications, get, prod-web/*, allowp, role:web-deployer, applications, sync, prod-web/*, allow# map an identity-provider group onto that roleg, 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.
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'
YesNo
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.
kubectl -n argocd get appproject default -o yaml | grep -A8 '^spec:'
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.
apiVersion: argoproj.io/v1alpha1kind: AppProjectmetadata:name: prod-webnamespace: argocdspec:description: Production web tiersourceRepos:- https://github.com/acme/prod-web-manifests.gitdestinations:- server: https://prod-a.k8s.acme.internalnamespace: webclusterResourceWhitelist: [] # forbid ALL cluster-scoped resourcesnamespaceResourceBlacklist:- group: rbac.authorization.k8s.iokind: RoleBindingsignatureKeys:- 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.
kubectl -n argocd get application prod-web-checkout \-o jsonpath='{.status.conditions[*].message}'; echo
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.
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.
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.
argocd account list
NAME ENABLED CAPABILITIESadmin 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.