Skip to main content

Push from GitHub Actions Without Stored Credentials

Push images from a workflow with no registry password anywhere: no repository secret, no service account, nothing to rotate. GitHub mints a short-lived OIDC token per job and Angos validates it against GitHub's public keys.

This page covers the workflow side. For the registry side, see Configure GitHub Actions OIDC.

Prerequisites

A provider entry trusting GitHub, whose key is the username clients will send:

[auth.oidc.github-actions]
issuer = "https://token.actions.githubusercontent.com"
required_claims = ["repository", "actor"]

required_claims rejects a token missing either claim before any policy runs, so a rule reading claims["repository"] can never evaluate against an absent value.

Optionally add the token service, so the credential only has to be valid when a push starts rather than for its whole duration:

[auth.token_service]
realm = "https://registry.example.com/token"
ttl_secs = 3600
secret_key = "..." # openssl rand -base64 32

Keep secret_key in a second -c file so rotating it never touches the file your deployment tooling renders. Both files are watched, so the rotation applies without a restart.

How a Client Authenticates

Angos reads an OIDC token from either of two places, so any client that speaks HTTP basic auth can present one:

FormUsernamePassword
Authorization: Bearer <token>
Authorization: Basic <base64>the [auth.oidc.<name>] keythe token

The second form is why docker login, Kaniko, Buildah and anything else reading a config.json work unchanged. The username is not a user: it names the provider entry that should validate the password.

Step 1: Grant the Job an OIDC Token

permissions:
contents: read
id-token: write # without this the token endpoint is not injected

Step 2: Request the Token

- name: Get OIDC token
id: oidc
run: |
TOKEN=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://registry.example.com" \
| jq -r '.value')
echo "::add-mask::$TOKEN"
echo "token=$TOKEN" >> "$GITHUB_OUTPUT"

audience names the container registry, not the GitHub repository, so required_audience can pin one value for every workflow that pushes. It is public: a token minted for another service is refused, nothing is kept secret.

::add-mask:: keeps the token out of the job log. Request it close to the push: it is valid for about ten minutes, which has to cover everything up to the registry's first request.

Step 3: Push

Kaniko

Kaniko takes credentials only through a Docker config file, so write one:

- name: Build and push
env:
REGISTRY_PASSWORD: ${{ steps.oidc.outputs.token }}
run: |
install -m600 /dev/null kaniko-secret.json
jq -n --arg auth "$(printf 'github-actions:%s' "$REGISTRY_PASSWORD" | base64 -w0)" \
'{auths: {"registry.example.com": {auth: $auth}}}' > kaniko-secret.json

docker run -i --rm -v "$PWD:/workspace" \
-v "$PWD/kaniko-secret.json":/kaniko/.docker/config.json:ro \
gcr.io/kaniko-project/executor:v1.18.0 \
--dockerfile=Dockerfile \
--destination=registry.example.com/myorg/myapp:"$IMAGE_TAG"

install -m600 creates the file unreadable by other users before anything is written to it, and jq --arg passes the token as an argument rather than interpolating it into a command line other processes could read.

Docker

- name: Log in
run: |
echo "${{ steps.oidc.outputs.token }}" | \
docker login registry.example.com --username github-actions --password-stdin

- name: Push
run: docker push registry.example.com/myorg/myapp:"$IMAGE_TAG"

Buildx

docker/login-action writes the same config file, so it needs no special handling:

- uses: docker/login-action@v3
with:
registry: registry.example.com
username: github-actions
password: ${{ steps.oidc.outputs.token }}

- uses: docker/build-push-action@v6
with:
push: true
tags: registry.example.com/myorg/myapp:${{ github.sha }}

build-push-action builds and pushes in one step, so a long build eats into the token's ten minutes before the push starts. Either build first with push: false and push in a later step, or enable the token service so the credential only has to be valid when the push begins.

Restricting Who May Push

The token's claims reach the access policy, so a rule can name the repository, the branch, or the workflow that produced it.

[global.access_policy]
default = "deny"
rules = [
"request.action in ['healthz', 'readyz', 'metrics']",
"identity.username != null",
"has_repository_policy()",
]

[repository."myorg/website".access_policy]
default = "deny"
rules = [
'identity.id == "admin"',
'''identity.oidc != null &&
identity.oidc.claims["repository"] == "myorg/website-frontend"''',
]

has_repository_policy() is what keeps the global rules from admitting a namespace no [repository] covers: a blanket identity.oidc != null there lets any GitHub Actions token, from any repository on GitHub, push to a namespace you never configured. See Policy evaluation for how the two layers combine.

More examples in Configure GitHub Actions OIDC.

Troubleshooting

SymptomCause
ACTIONS_ID_TOKEN_REQUEST_URL is emptythe job is missing id-token: write
401 on the first push requestthe username does not match an [auth.oidc.<name>] key
401 part-way through a pushthe token expired mid-push; see the token service above
403 after a successful loginthe token validated but no policy rule admits its claims

Run the registry with RUST_LOG=angos::auth=debug to see which provider accepted or rejected a token, and RUST_LOG=angos::policy=debug to see how rules evaluated against its claims.

Next Steps