Skip to main content

Configure GitHub Actions OIDC

Set up Angos to accept GitHub Actions OIDC tokens for passwordless authentication from CI/CD pipelines.

Prerequisites

  • Angos running with network access from GitHub Actions
  • GitHub repository with Actions enabled

Configure the Registry

Step 1: Add OIDC Provider

Add GitHub's issuer to config.toml:

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

jwks_uri is optional: leave it out and the registry discovers it from the issuer's .well-known/openid-configuration. required_claims rejects a token that does not carry the claims a workflow token always has, before any access policy runs. OIDC tokens must use an allowed JWT signing algorithm; the default allowlist is ["RS256"].

Step 2: Add Access Policy

Configure which repositories can access your registry:

[global.access_policy]
default = "deny"
rules = [
# Allow GitHub Actions from your organization
"identity.oidc != null && identity.oidc.claims['repository'].startsWith('myorg/')"
]

Step 3: Restart the Registry

./angos -c config.toml server

Configure GitHub Actions

Step 1: Add Permissions

Your workflow needs id-token: write permission:

name: Push to Registry
on: push

jobs:
push:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read

Step 2: Get and Use the Token

steps:
- uses: actions/checkout@v4

- name: Get OIDC Token
id: get-token
run: |
TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://github.com/${{ github.repository }}" \
| jq -r '.value')
echo "::add-mask::$TOKEN"
echo "token=$TOKEN" >> $GITHUB_OUTPUT

- name: Login to Registry
run: |
echo "${{ steps.get-token.outputs.token }}" | \
docker login registry.example.com \
--username github-actions \
--password-stdin

- name: Build and Push
run: |
docker build -t registry.example.com/myapp:${{ github.sha }} .
docker push registry.example.com/myapp:${{ github.sha }}

The username must match the provider name (github-actions in this example).

For Kaniko, Buildx and the rest of the workflow side, see Push from GitHub Actions.


Long-Running Pushes

A GitHub Actions OIDC token is valid for about ten minutes and that lifetime cannot be extended. The registry checks it on every request, so a push still running when the token expires fails part-way through.

Enable the token service to decouple the two. The exchange is reactive rather than up front: the client's first request is refused with a 401 carrying the challenge, the client follows it to /token, and it uses the token it gets back for the rest of the push. Under a deny-by-default policy that refused request is the GET /v2/ ping, so the OIDC token only has to be valid at the start of the push instead of for its whole duration:

[auth.token_service]
secret_key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" # openssl rand -base64 32
ttl_secs = 3600

Under a default-deny policy, allow the exchange:

[global.access_policy]
default = "deny"
rules = [
"request.action == 'get-token' && identity.oidc != null",
"identity.oidc != null && identity.oidc.claims['repository'].startsWith('myorg/')",
]

No workflow change is needed, and this is not Docker-specific: the exchange is the registry v2 bearer-token flow that every OCI client implements, so Docker, Podman, Buildah, Skopeo, containerd, BuildKit, Kaniko, crane and ORAS all discover the endpoint from the registry's WWW-Authenticate header on their own. Fetch the OIDC token close to the push rather than at the start of the job, so the ten minutes covers the build as well.

A token that expires mid-push is answered with a fresh challenge and the client exchanges again, but only while the credential it started from is still valid, which here is the same ten minutes. Size ttl_secs to the longest push you expect rather than to the maximum allowed: a registry token cannot be revoked before it expires. A client whose credentials do not expire, such as basic auth, recovers from any expiry and is fine with a short one.


Policy Examples

Allow Specific Repositories

[repository."myapp".access_policy]
default = "deny"
rules = [
'''identity.oidc != null &&
identity.oidc.claims["repository"].matches("^myorg/(app1|app2|app3)$")'''
]

Allow Main Branch Only

[repository."production".access_policy]
default = "deny"
rules = [
'''identity.oidc != null &&
identity.oidc.claims["ref"] == "refs/heads/main" &&
identity.oidc.claims["repository"].startsWith("myorg/")'''
]

Allow Release Tags Only

[repository."releases".access_policy]
default = "deny"
rules = [
'''identity.oidc != null &&
identity.oidc.claims["ref"].matches("^refs/tags/v[0-9]+\\.[0-9]+\\.[0-9]+$")'''
]

Allow Specific Workflows

rules = [
'''identity.oidc != null &&
identity.oidc.claims["workflow"].matches("^(deploy|release)\\.yml$")'''
]

Allow Specific Environments

rules = [
'''identity.oidc != null &&
identity.oidc.claims["environment"] == "production"'''
]

Allow Specific Actors

rules = [
'''identity.oidc != null &&
identity.oidc.claims["actor"] in ["myuser", "dependabot[bot]", "renovate[bot]"]'''
]

Complete Example

config.toml

[server]
bind_address = "0.0.0.0"
port = 8000

[server.tls]
server_certificate_bundle = "/tls/server.crt"
server_private_key = "/tls/server.key"

[blob_store.fs]
root_dir = "/data"

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

# Production: only main branch from specific repos
[repository."production".access_policy]
default = "deny"
rules = [
'''identity.oidc != null &&
identity.oidc.provider_name == "github-actions" &&
identity.oidc.claims["ref"] == "refs/heads/main" &&
identity.oidc.claims["repository"].matches("^myorg/(api|web|worker)$")'''
]

# Dev: any branch from org
[repository."dev".access_policy]
default = "deny"
rules = [
'''identity.oidc != null &&
identity.oidc.claims["repository"].startsWith("myorg/")'''
]

[ui]
enabled = true

GitHub Workflow

name: Build and Push
on:
push:
branches: [main]
tags: ['v*']

env:
REGISTRY: registry.example.com
IMAGE_NAME: ${{ github.repository }}

jobs:
build:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read

steps:
- uses: actions/checkout@v4

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

- name: Login to Registry
run: |
echo "${{ steps.oidc.outputs.token }}" | \
docker login $REGISTRY --username github-actions --password-stdin

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

Verification

Check the registry logs for authentication details:

RUST_LOG=angos::auth=debug ./angos server

You should see:

OIDC token validated for provider 'github-actions' (type='GitHub Actions', sub=Some("repo:myorg/myrepo:ref:refs/heads/main"), iss=Some("https://token.actions.githubusercontent.com"))

The log only includes the provider name/type and the sub/iss claims; the full claims map is never logged, to avoid leaking user/CI metadata.


Troubleshooting

Token rejected:

  • Check id-token: write permission is set
  • Verify the token audience matches expectations
  • Enable debug logging to see validation details

Policy not matching:

  • Always check identity.oidc != null first
  • Use bracket notation for claims: identity.oidc.claims["claim"]
  • Check the policy module's debug logs (RUST_LOG=angos::policy=debug) to see how CEL rules evaluate against the stored identity.oidc.claims map; the OIDC auth log only emits provider name/type and the sub/iss claims to avoid leaking user/CI metadata

Network errors:

  • Ensure the registry can reach token.actions.githubusercontent.com for JWKS

Next Steps