Argo CD hardening, the secure way
Argo CD is the one component in your cluster that can create anything, anywhere, from whatever a Git repository says. Out of the box it trusts that repository, its admin password and its default project a great deal. Time to trust them less.
The short answer
Disable the built-in admin after SSO works, set policy.default to empty, and give each team a role that can get and sync only its own project. Empty the default AppProject. Scope every AppProject to one repository and its own namespaces, with no cluster-scoped kinds and never the argocd namespace. Keep exec and anonymous access off.
On this page
What goes wrong
Argo CD runs with enough Kubernetes permissions to apply whatever the Git repository says. So Argo CD's own controls, not the cluster's, decide who can deploy what. The defaults are made for a first demo:
- A built-in
adminuser with full access and a password in a Secret. - A
defaultproject that accepts any repository, any cluster and any kind. - RBAC examples that give everyone
role:readonly, which shows every application, its manifests and its parameters to any logged-in user.
The worst case is not a stolen UI password. It is an Application in a
project that can deploy into the argocd namespace. That Application can
change Argo CD itself, and from there, everything.
What the docs say
In short, gaining unauthorized write access to a git repository trusted by Argo CD will have serious security implications outlined below.
Source: Argo CD docs, Security
If unspecified, an application belongs to the
defaultproject, which is created automatically and by default, permits deployments from any source repo, to any cluster, and all resource Kinds.
Source: Argo CD docs, Projects
Projects with access to the namespace in which Argo CD is installed effectively have admin-level privileges.
Source: Argo CD docs, Cluster Bootstrapping
All authenticated users get at least the permissions granted by the default policies. This access cannot be blocked by a
denyrule.
Source: Argo CD docs, RBAC Configuration
The docs explain each risk on its own page. They do not put the fixes in one
place, and the reference argocd-rbac-cm.yaml still shows
policy.default: role:readonly, which is the setting the bold warning
above is about.
The secure configuration
Apply these after SSO login works for at least two platform admins.
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
labels:
app.kubernetes.io/name: argocd-cm
app.kubernetes.io/part-of: argocd
data:
url: https://argocd.example.com
admin.enabled: "false" # after SSO works; the built-in admin has full access
users.anonymous.enabled: "false" # no access without login
exec.enabled: "false" # no web terminal into pods (the default; keep it explicit)
accounts.ci: apiKey # a token-only account for CI, no UI login
statusbadge.enabled: "false" # badges leak app names and health to anyone with the URL
helm.valuesFileSchemes: "" # no remote Helm values files fetched at render time
oidc.config: |
name: SSO
issuer: https://sso.example.com
clientID: argocd
clientSecret: $oidc.sso.clientSecret
requestedScopes: ["openid", "profile", "email", "groups"]
---
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-rbac-cm
namespace: argocd
labels:
app.kubernetes.io/name: argocd-rbac-cm
app.kubernetes.io/part-of: argocd
data:
policy.default: "" # logged-in users with no role see nothing
scopes: "[groups]"
policy.csv: |
# Platform team: full control.
g, platform-admins, role:admin
# Team A: read and sync its own project only. No create, no delete, no exec.
p, role:team-a, applications, get, team-a/*, allow
p, role:team-a, applications, sync, team-a/*, allow
p, role:team-a, logs, get, team-a/*, allow
g, team-a-developers, role:team-a
# CI: may only sync the apps it deploys; it never edits Applications.
# "argocd app sync" reads the app first, so get is needed too.
p, role:ci, applications, get, team-a/*, allow
p, role:ci, applications, sync, team-a/*, allow
g, ci, role:ci
---
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cmd-params-cm
namespace: argocd
labels:
app.kubernetes.io/name: argocd-cmd-params-cm
app.kubernetes.io/part-of: argocd
data:
server.insecure: "false" # the API server terminates TLS itself
---
# The built-in "default" project allows any repo, any cluster, any kind.
# Empty it so an Application that forgets its project cannot deploy anything.
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: default
namespace: argocd
spec:
sourceRepos: []
destinations: []
clusterResourceWhitelist: []
namespaceResourceWhitelist: []
---
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: team-a
namespace: argocd
spec:
description: Team A applications
sourceRepos:
- https://git.example.com/team-a/deploy.git # one repository, not "*"
destinations:
- server: https://kubernetes.default.svc
namespace: team-a # never the argocd namespace
clusterResourceWhitelist: [] # no cluster-scoped objects at all
namespaceResourceBlacklist:
- { group: "", kind: ResourceQuota }
- { group: "", kind: LimitRange }
- { group: networking.k8s.io, kind: NetworkPolicy }
- { group: rbac.authorization.k8s.io, kind: Role }
- { group: rbac.authorization.k8s.io, kind: RoleBinding }
orphanedResources:
warn: trueFour more settings live outside these files:
- Repository credentials: one read-only deploy key per repository, scoped
to its project with the
projectfield of the repository Secret. The docs say a credential without that field "will be used as the default for all projects without a scoped credential". - The
argocdnamespace gets a NetworkPolicy: the API server accepts traffic only from your ingress, and the repo server accepts traffic only from the application controller and the API server. Limit the repo server's egress too, to DNS and your Git and Helm hosts: it fetches whatever URL an Application names. - Only platform admins can push to the repository that holds Applications and AppProjects.
- Generate the CI token with
argocd account generate-token --account ci --expires-in 720hand rotate it before it expires.
Prove it
The manifests pass schema validation:
kubeconform -strict -summary -kubernetes-version 1.34.0 \
-schema-location default \
-schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \
argocd-hardening.yamlThen on a lab cluster with Argo CD v3.5.3 (install.yaml), in two steps:
everything except admin.enabled: "false" first, so the admin could create
the CI token, then the full files. The team project's repository was the
public argoproj/argocd-example-apps in place of the team's own.
1. Before hardening, default deploys anything:
$ argocd app create gb-default --project default --repo https://github.com/argoproj/argocd-example-apps.git --path guestbook ...
application 'gb-default' created
$ argocd app get gb-default -o json | jq -r '"\(.spec.project) \(.status.sync.status)"'
default Synced2. After hardening, the projects hold:
$ argocd app create gb-default2 --project default ...
... InvalidSpecError: application repo https://github.com/argoproj/argocd-example-apps.git is not permitted in project 'default'
$ argocd app get gb-default -o json | jq -r '.status.conditions[] | "\(.type): \(.message)"'
InvalidSpecError: application destination server 'https://kubernetes.default.svc' and namespace 'guestbook-default' do not match any of the allowed destinations in project 'default'
InvalidSpecError: application repo https://github.com/argoproj/argocd-example-apps.git is not permitted in project 'default'
$ argocd app create web --project team-a --repo <the allowed repo> --path guestbook --dest-namespace team-a ...
application 'web' created
Phase: Succeeded
$ argocd app create web-argocd --project team-a ... --dest-namespace argocd
... InvalidSpecError: application destination server 'https://kubernetes.default.svc' and namespace 'argocd' do not match any of the allowed dest...
$ argocd app create web-other2 --project team-a --repo https://github.com/stefanprodan/podinfo.git ...
... InvalidSpecError: application repo https://github.com/stefanprodan/podinfo.git is not permitted in project 'team-a'The existing app in default stopped syncing the moment the project was
emptied. Nothing new could be deployed through it.
3. The built-in admin:
$ argocd login --username admin --password ...
{"level":"fatal","msg":"rpc error: code = Unauthenticated desc = Account admin is disabled"}4. The CI token. With only sync in its role, as in the first version
of this page, the CLI could not sync:
$ ARGOCD_AUTH_TOKEN=... argocd app sync web
{"level":"fatal","msg":"rpc error: code = PermissionDenied desc = permission denied"}argocd app sync calls Get on the application first, even with
--async. With get and sync:
sync: Phase: Succeeded
set (update): {"level":"fatal","msg":"rpc error: code = PermissionDenied desc = permission denied"}
delete: {"level":"fatal","msg":"rpc error: code = PermissionDenied desc = permission denied: applications, delete, team-a/web, s...5. The RBAC table, against the live argocd-rbac-cm saved to a file:
$ argocd admin settings rbac validate --policy-file argocd-rbac-cm.yaml
Policy is valid.
team-a-developers get applications team-a/web Yes
team-a-developers sync applications team-a/web Yes
team-a-developers delete applications team-a/web No
team-a-developers sync applications team-b/api No
team-a-developers create exec team-a/web No
ci get applications team-a/web Yes
ci sync applications team-a/web Yes
ci update applications team-a/web No
someone-else get applications team-a/web No
platform-admins delete applications team-b/api YesEach line is argocd admin settings rbac can <subject> <action> <resource> <object> --policy-file argocd-rbac-cm.yaml. A No exits with code 1 and a
Yes with 0, so the same lines work as a CI check on every change to
argocd-rbac-cm. Without --policy-file, the command reads the live
ConfigMap through your current kubeconfig, so always pass it in CI.
Mistakes people make
policy.default: role:readonly
It looks harmless. It lets every logged-in user read every application's
manifests, parameters and events, and a deny rule cannot take it back. Use
an empty default and grant read per project.
Letting a project deploy to the argocd namespace
A destination of namespace: "*" includes argocd. Anyone who can merge to
that project's repository can then rewrite Argo CD's ConfigMaps. List the
namespaces a project may use; never use * for application teams.
sourceRepos: ["*"]
With a wildcard, a developer can point an Application at any repository, including a fork they control. One repository, or a short list, per project.
Turning off admin before SSO works
Test SSO and the platform-admins group first. Keep a break-glass path, such as a documented way to re-enable admin through the ConfigMap by someone with cluster access, and audit its use.
One CI token with role:admin
CI only needs to sync. A pipeline token with admin can create Applications in any project, which is the same as admin on every cluster Argo CD manages.
Checklist
- SSO works for at least two platform admins.
admin.enabled: "false"andusers.anonymous.enabled: "false"are set.exec.enabledis"false"unless a documented need exists.policy.defaultis empty.- Each team role can get and sync only its own project.
- The CI account has
apiKeyonly, sync only, and a token with an expiry. - The
defaultAppProject has no sources, destinations or kinds. - Every AppProject lists its repositories and namespaces; none includes
argocd. - Cluster-scoped kinds are allowed only in the platform project.
- Repository credentials are read-only and scoped with the
projectfield. - RBAC checks with
argocd admin settings rbac can --policy-filerun in CI.
Argo CD does exactly what Git says. Hardening it is deciding, in writing, whose Git that is.
H2-CSDE
Learn it on a live range
Policy gates and scanning, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.
Start freeH2 Scanner
Want this caught before it merges?
The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.
Talk to us