Killing the ArgoCD token: trading GitHub Actions OIDC for scoped sync access

How to let GitHub Actions diff and sync ArgoCD applications without a static ArgoCD API token in CI secrets, using ArgoCD's own built-in Dex for a token exchange and RBAC scoped to the GitHub OIDC claims.

André LorethAndré Loreth··11 min read
Share

We already had a broker that trades a GitHub Actions OIDC token for a scoped GitHub App credential, so CI never carries a GitHub PAT. ArgoCD had the exact same problem in a different shape: an ARGOCD_TOKEN secret, minted once by a person, pasted into a CI secret, and used by every workflow that needs to sync an application. This post is how we closed that gap too, using the OIDC identity Actions already gives every run and Dex, the OIDC provider that ships inside ArgoCD by default.

The static token is the same problem again

Every ArgoCD instance can mint an API token for a local user or a project role, and it is tempting to generate one, drop it into ARGOCD_TOKEN, and call it done. It works, right up until you ask the questions that matter for any credential sitting in CI: who can see it, how long does it live, what can it actually do, and what happens when the person who created it leaves.

A static ArgoCD token answers all four badly. It lives until someone remembers to rotate it. It is usually scoped to whatever role was easiest to grant at the time, which in practice tends to be broader than any single workflow needs. It gives no per-run audit trail beyond “a request came in with this token.” And nothing about it is tied to the workflow run that used it, only to whoever generated it.

This is the same shape as the GitHub PAT problem we already solved for cross-repo access in CI. The fix is the same idea too: stop storing a long-lived credential at all, and mint a short-lived one per run instead, scoped to exactly what that run is allowed to do.

The building block we already have

GitHub Actions already mints an OIDC identity token for every workflow run, for free, the moment a job declares permissions: { id-token: write }. That token is signed by GitHub, carries claims about which repository and workflow triggered the run, and expires with the run. We already have a standard way to fetch one of these across every tool we run, covered in oidc-token-cli. The only thing missing for ArgoCD is something on the other end that ArgoCD trusts, and that is willing to turn that GitHub identity into an ArgoCD credential.

The trick: point ArgoCD’s own Dex at GitHub

Every standard ArgoCD install already runs Dex. It ships as the argocd-dex-server component and is proxied at /api/dex on the same host as the ArgoCD API and UI, no separate service, ingress, or deployment involved. It is configured entirely through the dex.config key that already exists in the argocd-cm ConfigMap. Nothing here requires standing up new infrastructure, only adding a connector to config you already have.

The connector configuration is the important part. GitHub’s sub claim for a workflow run looks like repo:acme-org/acme-infra:ref:refs/heads/main for a push to main, or repo:acme-org/acme-infra:pull_request for a pull request. Dex’s claimMapping remaps that sub claim straight into its own groups claim, so ArgoCD’s RBAC can match on it directly without any extra plumbing:

argocd-cm: dex.config connector
dex.config: |
connectors:
- type: oidc
id: github-actions
name: GitHub Actions
config:
issuer: https://token.actions.githubusercontent.com/
scopes: [openid]
userNameKey: sub
insecureSkipEmailVerified: true
insecureEnableGroups: true
overrideClaimMapping: true
claimMapping:
groups: sub

None of those fields below claimMapping are cosmetic. A GitHub Actions OIDC token carries no name, email, or groups claim, and its issuer supports only the openid scope, nothing broader. Dex’s defaults assume a normal user profile: without userNameKey: sub it looks for a name claim and fails, without scopes: [openid] it asks GitHub for profile email and gets claims that do not exist, and without insecureSkipEmailVerified: true it refuses a token that never had an email in the first place. insecureEnableGroups and overrideClaimMapping are what let claimMapping.groups take effect at all.

There is no separate oidc.config block to write, because there is no separate issuer to trust, ArgoCD already trusts its own Dex. There is also nothing to turn on for the exchange itself: Dex has supported RFC 8693 token exchange by default since 2.38, and every ArgoCD release in the last several years ships a Dex version well past that, so the grant type is already enabled without touching oauth2 at all.

GitHub OIDC identity exchanged for a token from ArgoCD's own Dex

sequenceDiagram
  participant W as Workflow (Actions)
  participant G as GitHub OIDC provider
  participant D as ArgoCD Dex (built-in)
  participant A as ArgoCD API server
  W->>G: request OIDC token (audience=argocd server URL)
  G-->>W: signed id_token (sub=repo:acme-org/acme-infra:ref:refs/heads/main)
  W->>D: token-exchange grant at /api/dex/token (subject_token, connector_id=github-actions)
  D->>D: claimMapping remaps sub into groups
  D-->>W: Dex id_token (groups includes the sub value)
  W->>A: app diff / app sync (Authorization: Dex id_token)
  A->>A: RBAC match on groups claim
  A-->>W: result

Once that token comes back from Dex, it carries a groups claim that is literally the GitHub sub value, and ArgoCD’s RBAC engine reads that claim like it would for any other OIDC group.

Scoping access with RBAC

The exchange only gets you a token ArgoCD will accept. What that token can do is entirely down to AppProject roles matched on the groups claim. This is where the actual least-privilege decision lives, and it is worth writing deliberately rather than granting one broad role to anything with a valid token.

A push to the default branch gets sync rights. A pull request gets read and diff rights only, never sync:

AppProject: roles scoped to GitHub OIDC groups
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: acme-infra
namespace: argocd
spec:
roles:
- name: github-actions-sync
description: Push to main on acme-org/acme-infra can sync
policies:
- p, proj:acme-infra:github-actions-sync, applications, get, acme-infra/*, allow
- p, proj:acme-infra:github-actions-sync, applications, sync, acme-infra/*, allow
groups:
- "repo:acme-org/acme-infra:ref:refs/heads/main"
- name: github-actions-diff
description: Pull requests against acme-org/acme-infra can read and diff only
policies:
- p, proj:acme-infra:github-actions-diff, applications, get, acme-infra/*, allow
groups:
- "repo:acme-org/acme-infra:pull_request"

github-actions-sync only matches the exact sub value GitHub issues for a push to main, so a token minted from any other branch or event never lands in that group. github-actions-diff matches any pull request against the repo and grants get only, which is enough for argocd app diff but nothing that mutates state.

The workflow itself

With the connector in place, the workflow side is a curl exchange against ArgoCD’s own /api/dex/token endpoint and two argocd invocations. Diff on a pull request, sync on push to main, and the OIDC token never comes from a stored secret:

.github/workflows/deploy.yml
name: deploy
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
argocd:
runs-on: ubuntu-latest
steps:
- name: Exchange GitHub OIDC token for an ArgoCD token
id: auth
run: |
GITHUB_OIDC_TOKEN=$(curl -sS \
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://argocd.example.com" \
| jq -r '.value')
ARGOCD_TOKEN=$(curl -sS -X POST https://argocd.example.com/api/dex/token \
--user 'argo-cd-cli:' \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token="${GITHUB_OIDC_TOKEN}" \
-d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
-d requested_token_type=urn:ietf:params:oauth:token-type:id_token \
-d scope="openid groups" \
-d connector_id=github-actions \
| jq -r '.access_token')
echo "::add-mask::${ARGOCD_TOKEN}"
echo "token=${ARGOCD_TOKEN}" >> "${GITHUB_OUTPUT}"
- name: Diff on pull request
if: github.event_name == 'pull_request'
run: |
argocd --auth-token "${{ steps.auth.outputs.token }}" --grpc-web \
--server argocd.example.com \
app diff acme-infra-app
- name: Sync on push to main
if: github.event_name == 'push'
run: |
argocd --auth-token "${{ steps.auth.outputs.token }}" --grpc-web \
--server argocd.example.com \
app sync acme-infra-app
argocd --auth-token "${{ steps.auth.outputs.token }}" --grpc-web \
--server argocd.example.com \
app wait acme-infra-app --health

ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL are already present as environment variables on any job that declares id-token: write, so nothing has to be configured to get the first token. argo-cd-cli is one of the static clients ArgoCD’s embedded Dex always registers for its own CLI, it is a public client with no secret, which is why --user 'argo-cd-cli:' works with an empty password. It also has to be that specific client: ArgoCD hardcodes the audiences its own Dex will issue tokens for to argo-cd and argo-cd-cli, so a made-up client ID here just gets rejected. --grpc-web is there because a plain gRPC connection over a standard ingress often gets mangled by proxies that were not built for it; ArgoCD’s own CLI ships this flag exactly for that case.

There is no argocd login step anywhere, and no long-lived token ever touches a GitHub secret. What lands in ARGOCD_TOKEN-shaped form here lives for one job and is masked the moment it exists.

Gotchas

A few things worth knowing before you copy this:

  • Tokens are ephemeral and masked in logs. ::add-mask:: keeps the exchanged token out of the workflow log, and the token itself has nothing standing to leak once the job finishes, because it was never written anywhere persistent.
  • Request the GitHub OIDC token with a specific audience. Without it, the token GitHub issues is more broadly reusable than it needs to be. Scoping it to your ArgoCD server’s URL means it is only good for this one exchange, not for authenticating against whatever else happens to also consume GitHub’s OIDC issuer.
  • The subject token type has to be id_token, not access_token. It is an easy field to get wrong, and getting it wrong fails in a confusing way: exchanging an access token routes Dex down a path that tries to call GitHub’s userinfo endpoint, which does not exist for this issuer, so the error looks unrelated to the actual mistake.
  • RBAC scopes at the AppProject, not the Application. Covered above, but worth repeating: a sync-capable token from your GitOps repo’s main branch can sync anything in that project.

Where this is heading

The curl exchange above is the honest, dependency-free version of this. The same mechanism is what oidc-token-cli already does with its --grant-type token-exchange support, so the two curl calls in the workflow above collapse into one command and the shell scripting goes away. This is the third time we have pointed the same house pattern, OIDC identity plus claim-based authorization, at a different target: oidc-token-cli fetches the identity, gh-token-broker trades that identity for a scoped GitHub credential, and this trades it for an ArgoCD one. Same shape, different door.

ArgoCD’s own documentation now covers this exact flow too, which is a good sign it is a supported pattern rather than something held together by undocumented behavior.

Share