In a GitOps estate, whoever can change the Git repository or the GitOps controller can change production, so the repository, the controller and the credentials between them become your most privileged attack surface. Argo CD and Flux, both CNCF projects, pull desired state from Git (or OCI and Helm repositories) and reconcile Kubernetes clusters towards it continuously. That model removes cluster credentials from CI pipelines, which is a real security gain, but it concentrates trust in a controller that often runs with cluster-admin rights and holds repository, cluster and decryption credentials. This guide covers the OpenGitOps principles and what they mean for security, hardening Argo CD and Flux (SSO, RBAC, projects and tenancy), repository and cluster credentials as non-human identities, secrets handling with Sealed Secrets, External Secrets and SOPS, commit verification, drift and rollback, and detection. It is written for platform engineers, Kubernetes administrators and security architects.
Key takeaways
- Git write access is production access. Branch protection, required reviews and signed commits on the configuration repository are deployment controls, not developer hygiene.
- The controller is a privileged identity. Argo CD's default install uses a cluster-admin-level role, and Flux binds cluster-admin to kustomize-controller and helm-controller; scope both down for multi-team clusters.
- Repository and cluster credentials are NHIs. Deploy keys, tokens, GitHub App keys and the argocd-manager service account token need owners, least privilege, rotation and revocation like any other machine credential.
- Keep secret values out of the controller. Argo CD's own documentation strongly recommends populating secrets on the destination cluster rather than injecting them during manifest generation.
- Tenancy is opt-in. Argo CD's
defaultproject and Flux's default settings are permissive; AppProjects, impersonation and the Flux lockdown flags have to be configured deliberately.
How GitOps changes the threat model
OpenGitOps defines four principles in version 1.0.0: desired state is declarative; it is versioned and immutable with a complete history; it is pulled automatically by software agents; and it is continuously reconciled by agents that compare actual and desired state. Each principle has a security consequence.
- Declarative and versioned: every change is a commit, so the repository history is the change record (NIST SP 800-53 CM-3) and the baseline (CM-2). That only holds if history cannot be rewritten: block force pushes and branch deletion on deployment branches.
- Pulled automatically: the controller runs inside or next to the cluster and fetches state, so CI pipelines no longer need a kubeconfig. Pipeline compromise no longer equals cluster compromise, but repository compromise does.
- Continuously reconciled: manual changes made with kubectl are reverted (if self-heal or equivalent is enabled), which removes a common persistence route but also reverts emergency fixes made outside Git.
The trust boundary moves from "who can run kubectl" to "who can merge to the config repository, who can change controller configuration, and what the controller's own identity is allowed to do". MITRE ATT&CK techniques that map to this boundary include Compromise Software Supply Chain (T1195.002) through a poisoned repository or chart, Credentials In Files (T1552.001) for keys committed to Git, Container API (T1552.007) for secrets read through the Kubernetes API, Deploy Container (T1610) once an attacker controls what the controller applies, and Cloud Accounts (T1078.004) for stolen cloud credentials used by controllers. See the CI/CD Security Guide for the pipeline side, which this guide does not repeat.
Argo CD and Flux: security-relevant differences
| Area | Argo CD | Flux |
|---|---|---|
| User interface and API | Web UI and API server; API accepts JWTs only (local admin, SSO via Dex or an OIDC provider, project role tokens) | No built-in UI; users act through Git and the Kubernetes API, so Kubernetes RBAC governs access |
| Authorisation model | Own RBAC in argocd-rbac-cm (Casbin syntax) plus AppProject roles | Kubernetes RBAC; flux-view and flux-edit ClusterRoles aggregate into view, edit and admin |
| Default controller privilege | Cluster-admin-level ClusterRoles for argocd-application-controller and argocd-server; argocd-manager-role on external clusters | cluster-reconciler binds cluster-admin to kustomize-controller and helm-controller |
| Tenant isolation | AppProjects: sourceRepos, destinations, resource allow and deny lists, project roles | Impersonation of a tenant service account via spec.serviceAccountName; lockdown flags |
| Rendering risk | repo-server runs Helm, Kustomize and config management plugins; disable tools you do not use | kustomize-controller and helm-controller render; --no-remote-bases blocks Kustomize remote bases |
| In-cluster data stores | Redis caches generated manifests; plain connection by default unless TLS is configured | No equivalent shared cache in the core controllers |
| Source verification | GPG commit verification per project (see Source Integrity Verification in the Argo CD docs) | spec.verify.mode HEAD, Tag or TagAndHEAD with PGP (.asc) or SSH (.sshpub) keys |
Choosing between them is rarely a security decision. Argo CD gives a UI and fine-grained application RBAC, which suits organisations where many teams need self-service visibility, but adds an internet-facing API server, local accounts and a token issuer to secure. Flux has a smaller surface and relies on Kubernetes RBAC, which suits platform teams that already manage RBAC well and do not want another authorisation layer. A small team on one cluster can start with SSO and branch protection; multi-tenant or regulated estates need the tenancy controls below from day one.
Hardening Argo CD access: SSO, local accounts and RBAC
- Use SSO and disable the admin account. Configure an existing OIDC provider under
oidc.configinargocd-cm(or Dex underdex.config), map identity provider groups to roles, then setadmin.enabled: "false". The admin password is a bcrypt hash inargocd-secret; changing it revokes existing admin tokens. - Set a minimal default role.
policy.defaultinargocd-rbac-cmapplies to every authenticated user and cannot be overridden by a deny rule, so set it to an empty or minimal custom role rather thanrole:readonlywhen read access exposes sensitive manifests. Leaveusers.anonymous.enabledoff. - Write policy as code. Policies use
p, <subject>, <resource>, <action>, <object>, allow|denyandg, <group>, <role>; deny always wins. Application objects are scoped as<project>/<app>. Validate changes withargocd admin settings rbac validateand test withargocd admin settings rbac canin the pull request that changes them. - Restrict high-impact actions.
exec, creategives an interactive shell in pods;overridelets a user sync arbitrary manifests that are not in Git;clusters,repositoriesandprojectscreate, update and delete change the trust model itself. Reserve these for a small platform group, and from v3.2 considerapplication.sync.requireOverridePrivilegeForRevisionSync: 'true'so syncing an arbitrary revision also needs override. - Control local accounts. Local users are defined as
accounts.<name>with capabilitiesapiKeyand/orlogin. Give automation accountsapiKeyonly, and disable unused accounts withaccounts.<name>.enabled: "false". - Protect the API server. Do not expose the UI or API to the internet without SSO in front; set
--tlsminversion 1.2; log client IP addresses at the ingress or proxy, because Argo CD does not log them.
An unauthenticated cluster management interface has caused real incidents: in the Tesla Kubernetes cryptojacking incident, a Kubernetes console without a password let attackers find AWS credentials in a pod and run cryptominers. A GitOps UI with write or exec rights is at least as dangerous.
Projects, tenancy and controller privilege
Argo CD AppProjects
- Do not use the default project:
defaultis created automatically, cannot be deleted and allows any source repository, any destination and every cluster-scoped kind. Lock it down (emptysourceReposanddestinations,namespaceResourceBlacklistof*/*) and create one project per team or trust zone. - Constrain sources and destinations: list exact repositories in
sourceReposand exactserver/namespacepairs indestinations; negation with!is supported. SetpermitOnlyProjectScopedClusters: truewhere teams register their own clusters. - Constrain kinds: keep
clusterResourceWhitelistempty for application teams so they cannot create ClusterRoles, ClusterRoleBindings, CustomResourceDefinitions or admission webhooks; usenamespaceResourceBlacklistfor kinds such as ResourceQuota or NetworkPolicy that the platform team owns. - Project role tokens:
argocd proj role create-tokenissues JWTs that have no expiry by default; always pass-eand revoke withargocd proj role delete-token.
Flux multi-tenancy lockdown
| Flag | Controllers | Effect |
|---|---|---|
--no-cross-namespace-refs=true | kustomize, helm, notification, image-reflector, image-automation | A tenant cannot reference another tenant's sources or events |
--no-remote-bases=true | kustomize-controller | Kustomize remote bases are blocked, so only Flux sources change cluster state |
--default-service-account=default | kustomize, helm | Objects without spec.serviceAccountName run as the tenant namespace's permissionless default account |
With these flags, Flux reconciles each tenant through the Kubernetes impersonation API as the tenant's own service account, so that account's RoleBindings, not cluster-admin, bound what a tenant repository can create. Reconciliation fails if the binding is missing, which is the desired failure mode.
Reduce the controller's own rights
Argo CD documents that write access should be limited to the namespaces and kinds it should manage: edit argocd-manager-role on external clusters and the argocd-server and argocd-application-controller ClusterRoles locally, and use resource exclusions to hide kinds entirely. Running the GitOps control plane on a dedicated management cluster limits the blast radius if its namespace is compromised. See the Kubernetes RBAC Guide and Kubernetes Security Guide for the cluster controls underneath.
Where identity fits: repository and cluster credentials
A GitOps controller authenticates to every source and every cluster it manages. Each credential is a non-human identity with standing access to production.
| Credential | Where it lives | Risk | Better option |
|---|---|---|---|
| HTTPS username and token or PAT | Argo CD Secret labelled argocd.argoproj.io/secret-type: repository; Flux Secret with username/password or bearerToken | Often a person's PAT with access to every repository they can see | GitHub App or a read-only deploy token scoped to one repository |
| SSH deploy key | Secret (identity plus known_hosts in Flux; argocd-ssh-known-hosts-cm in Argo CD) | No expiry; write-enabled keys can push | Read-only deploy key per repository, pinned host keys |
| GitHub App | App ID, installation ID or owner, private key (Argo CD repo creds; Flux spec.provider: github) | Private key compromise grants every installed repository | Install on config repositories only, contents read-only |
| Cloud workload identity | Azure workload identity (both tools), AWS for CodeCommit in Flux, KMS access for SOPS | Over-broad cloud role bound to the controller service account | Federated identity scoped to one repository, registry or key |
| External cluster token | Secret in the argocd namespace for the argocd-manager service account; non-expiring on Kubernetes 1.24+ | Long-lived bearer token with cluster-admin-level rights | Cloud IAM authentication (for example EKS via get-token) and a scoped ClusterRole |
- Credential templates: Argo CD credential templates apply one credential to every repository under a URL prefix (longest prefix wins). A template on an organisation prefix silently grants the controller read access to new repositories.
- Never skip verification:
--insecure-skip-server-verificationdisables TLS and SSH host key checks; the Argo CD documentation limits it to non-production. - Rotation and revocation: for an external cluster, rotate by deleting the token Secret on the managed cluster and re-running
argocd cluster add; revoke by deleting the ServiceAccount, ClusterRole and ClusterRoleBinding and runningargocd cluster rm.
Treat these credentials in your NHI inventory with an owner and rotation date. See the Kubernetes NHI Security Guide and the Workload Identity Federation Guide.
Secrets in GitOps: Sealed Secrets, External Secrets and SOPS
Plain Kubernetes Secrets cannot be committed to Git. Argo CD's documentation strongly recommends populating secrets on the destination cluster (Sealed Secrets, External Secrets Operator, Secrets Store CSI Driver, Vault Secrets Operator) rather than injecting them during manifest generation with a plugin, because injection gives Argo CD access to the values and stores generated manifests in plaintext in Redis.
| Approach | What is in Git | Key or credential to protect | Choose when |
|---|---|---|---|
| Sealed Secrets | Encrypted SealedSecret created with kubeseal | Controller sealing keys (renewed about every 30 days; old keys kept) | No external secret store; small number of clusters |
| External Secrets Operator | ExternalSecret reference only; values stay in the provider | Credentials in SecretStore/ClusterSecretStore; operator can write secrets in all namespaces | A central secrets manager or cloud vault already exists; rotation needed |
| SOPS with Flux | Values encrypted with age, OpenPGP or cloud KMS; Flux decrypts via spec.decryption.provider: sops | Private key Secret in flux-system, or KMS access for kustomize-controller | Flux estates wanting encrypted values reviewable in pull requests |
| Plugin injection at render time | Placeholders | Secrets readable by repo-server; plaintext in Redis | Avoid; if unavoidable, restrict network access to Redis and repo-server |
- Sealed Secrets: keep the default
strictscope (bound to name and namespace) rather thancluster-wide; back up sealing keys after every renewal;kubeseal --re-encryptre-seals with the newest key, but key renewal is not secret rotation. If a sealing key leaks, rotate the underlying credentials. - External Secrets: give the operator's provider credentials least privilege, prefer namespaced
SecretStoreobjects for tenants over a sharedClusterSecretStore, and use admission policy (OPA Gatekeeper or Kyverno) to stop tenants referencing stores they do not own. - SOPS: encrypt only
dataandstringDatawithencrypted_regex: ^(data|stringData)$, back up the private key and remove it from laptops.
See the Secrets Management Guide for vault design and rotation.
Source integrity: commits, charts and rendering
- Verify commits: in Flux set
spec.verify.mode: HEAD(orTag/TagAndHEADfor tag-based releases) with trusted keys in a Secret; in Argo CD enable GPG signature verification for projects. Unsigned or wrongly signed revisions then fail to reconcile. - Protect the repository: required reviews on deployment branches, CODEOWNERS for platform paths (projects, RBAC, controller config), no force push, and separate repositories or paths for platform and application teams. See the GitHub and GitHub Actions Security Guide.
- Control what rendering can reach: Helm chart dependencies and remote Kustomize bases can pull from repositories outside the allow-list; pin versions, mirror charts internally and disable rendering tools you do not use. CVE-2022-24348 (CVSS 3.1 7.7, CWE-22) was a path traversal in Argo CD's Helm handling before 2.1.9 and 2.2.4 that could expose files such as credentials in YAML from other applications on the repo-server.
- Verify the controllers themselves: Flux controller and CLI images are signed with Cosign and ship an SPDX SBOM; verify signatures in admission before the GitOps control plane is upgraded. See the Software Supply Chain Security Guide.
Drift, rollback and emergency change
Drift is any difference between Git and the cluster: an attack (a manual RoleBinding), an emergency fix, or expected mutation by admission controllers.
- Decide on self-heal per environment: automatic correction in production removes attacker persistence through kubectl but also reverts emergency fixes; where self-heal is off, alert on OutOfSync and require a commit within an agreed window.
- Roll back through Git: revert the commit so history stays the record. A UI rollback or sync to an older revision leaves Git and the cluster disagreeing, and with automated sync it is undone at the next reconciliation.
- Prune deliberately: pruning deletes resources removed from Git. A malicious or mistaken commit that deletes manifests can delete workloads, so require review on deletions and consider disabling prune for stateful or cluster-scoped resources.
- Break-glass: keep a documented, audited path to suspend reconciliation (Flux
suspend, Argo CD disabling automated sync) during an incident, and make suspension itself an alert. See the Break-Glass Account Guide.
Implementation: a phased approach
- Inventory (week 1): list every GitOps instance, the clusters it manages, every repository and cluster credential, the controllers' ClusterRoles, and every user with write access to config repositories.
- Close the front door: SSO, admin disabled, minimal
policy.default, API not internet-facing, branch protection on deployment branches. Test by logging in as a non-platform user and confirming read-only or no access. - Introduce projects and tenancy in audit first: create AppProjects or Flux tenant service accounts that mirror today's access, deploy in a non-production cluster, watch for sync failures, then tighten
sourceRepos, destinations and kinds. Roll back by moving the application back to its previous project; keep the change in Git so the revert is one commit. - Replace credentials: swap personal PATs for GitHub Apps or read-only deploy keys, and static cluster tokens for cloud IAM where available; set expiry on every project token.
- Move secrets out: migrate plugin-injected secrets to destination-cluster tooling; pilot one application, confirm rotation works without a sync, then migrate the rest.
- Enforce integrity: turn on commit verification for one project, confirm signed commits reconcile and unsigned ones fail, then extend.
- Scope controller privilege last: restrict ClusterRoles once you know which kinds each instance really manages; a too-narrow role surfaces as sync errors, which you roll back by restoring the previous ClusterRole from Git.
Detection and measurement
- Controller logs: Argo CD tags security-relevant log lines with a
securityfield (1 low to 5 emergency) and a CWE where applicable; forward them to the SIEM. Not all security events are tagged yet, so also collect API request logs. - Kubernetes Events: Argo CD emits Events for application activity naming the actor where possible; persist them, because Kubernetes keeps Events only briefly.
- Kubernetes audit log: alert on changes to
argocd-cm,argocd-rbac-cm, AppProjects, Flux Kustomization or GitRepository objects outside Git, exec into pods via the GitOps API, and new Secrets of type repository or cluster. - Git provider audit: branch protection changes, new deploy keys, GitHub App installations and force pushes on config repositories.
| Metric | Target |
|---|---|
Applications in the Argo CD default project or Flux tenants without impersonation | 0 |
| Repository and cluster credentials that are personal tokens or have no expiry | Falling to 0 |
| Production deployments from verified (signed) commits | 100% |
| Mean time an application stays OutOfSync without an approved reason | Under the agreed window (for example 24 hours) |
Users with exec, override or cluster/repository write rights | Named platform group only, reviewed quarterly |
| Secrets injected at render time | 0 |
Common failure patterns
- Everything in the default project: any team that can create an Application can deploy any repository to any namespace, including cluster-scoped kinds.
- A personal PAT as the repository credential: the controller inherits a person's access to every repository and breaks when they leave; nobody rotates it.
- Exposed UI with broad defaults: an internet-facing API server,
policy.default: role:readonlyand SSO for the whole company lets anyone read every manifest and configuration, and sometimes exec. - Secrets injected at render time: plaintext manifests sit in Redis, which is unencrypted by default, so a foothold in the namespace yields every secret.
- Unprotected deployment branch: branch protection exists on the application repository but not on the config repository the controller watches.
- Controller left at cluster-admin across shared clusters: a tenant repository can create ClusterRoleBindings, so one compromised repository becomes cluster takeover.
Framework mapping
| Control area | NIST SP 800-53 Rev. 5 | NIST CSF 2.0 | MITRE ATT&CK |
|---|---|---|---|
| Git as baseline and change record | CM-2, CM-3, SA-10 | PR.PS-01 | T1195.002 |
| Who can change config and controllers | CM-5, AC-6 | PR.PS-01 | T1610, T1609 |
| Repository and cluster credentials | IA-5 | PR.AA-01 | T1552.001, T1078.004 |
| Secrets handling | IA-5, SC-28 | PR.DS-01 | T1552.007 |
| Commit and image verification | SI-7, SR-4 | PR.PS-02 | T1195.002 |
| Audit of GitOps actions | AU-2, AU-12 | DE.CM-09 | n/a |
Practitioner checklist
- Disable the Argo CD admin account after SSO is working and set a minimal
policy.default. - Lock down the
defaultAppProject and give every team its own project with explicit sources, destinations and kinds. - Enable
--no-cross-namespace-refs,--no-remote-basesand--default-service-accounton multi-tenant Flux installs. - Replace personal tokens with GitHub Apps or read-only deploy keys, and record every controller credential in the NHI inventory with an owner.
- Set expiry on all Argo CD project role tokens and prefer cloud IAM over static cluster tokens.
- Keep secret values on the destination cluster with Sealed Secrets, External Secrets or SOPS; never inject at render time.
- Protect config repository branches and require signed commits verified by the controller.
- Scope the controller's ClusterRoles to the kinds and namespaces it manages.
- Forward controller logs, Kubernetes Events and audit logs to the SIEM and alert on configuration changes outside Git.
- Rehearse rollback by Git revert and the break-glass suspend procedure.
Standards and references
- OpenGitOps: GitOps Principles v1.0.0
- Argo CD: Security
- Argo CD: RBAC Configuration
- Argo CD: Secret Management
- Flux: Security
- Flux: Multi-tenancy lockdown
- Bitnami Labs: Sealed Secrets
- NIST NVD: CVE-2022-24348
Related NHI Mgmt Group resources: CI/CD Security Guide · Kubernetes Security Guide · Secrets Management Guide · Kubernetes NHI Security Guide · Infrastructure as Code Security Guide