Repository with examples demonstrating how to use Harbor/8gears Container Registry with Workload Identity Federation, eliminating the need for static secrets in CI/CD pipelines and Kubernetes.
Workload Identity Federation allows Harbor to authenticate clients using short-lived JWTs instead of static robot account secrets. By establishing a trust relationship with an external Identity Provider (like GitHub Actions, GitLab CI, or Kubernetes), Harbor can validate tokens and map them to internal robot accounts based on specific claims.
- No static secrets: Eliminate the operational burden of managing pull secrets
- Enhanced security: Use ephemeral, workload-specific credentials
- Simplified rotation: No secret rotation required since tokens are short-lived
- Audit trail: Better traceability of which workload accessed the registry
- GitHub Actions
- GitLab CI
- Kubernetes 1.33 (via Service Account tokens)
- FluxCD
- Forgejo Actions (TBD)
This example demonstrates how to authenticate to Harbor from a GitHub Actions workflow using OIDC tokens.
-
Harbor Setup: Configure a Federated Identity Provider in Harbor:
- OpenID Configuration URL:
https://token.actions.githubusercontent.com/.well-known/openid-configuration - JWKS URI: Automatically discovered
- Issuer: Automatically discovered
- OpenID Configuration URL:
-
Robot Account: Create a federated robot account in Harbor with claim rules matching your GitHub repository:
iss:https://token.actions.githubusercontent.comaud:<your-registry-domain>(e.g.,macfly4200.8gears.ch) β The audience can be any string, but using your registry domain is recommended. This ensures tokens are scoped specifically to your registry and prevents token reuse across different services.repository:<owner>/<repo>(e.g.,myorg/myrepo)
See a successful run example.
name: Create Image and Push Using federated IDP
on:
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC JWT
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get OIDC token
id: oidc
run: |
echo "Requesting OIDC token"
RESPONSE=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=<your-registry-domain>")
TOKEN=$(echo "$RESPONSE" | jq -r '.value')
echo "TOKEN=$TOKEN" >> $GITHUB_ENV
- name: Build and Push Image
run: |
echo $TOKEN | docker login -u not-relevant --password-stdin <your-registry-domain>
docker build -t <your-registry-domain>/library/image:${{ github.sha }} .
docker push <your-registry-domain>/library/image:${{ github.sha }}
- name: Pull Image from Registry
run: |
echo $TOKEN | docker login -u not-relevant --password-stdin <your-registry-domain>
docker pull <your-registry-domain>/library/hello-world:latest-
Permissions: The workflow must have
id-token: writepermission to request OIDC tokens. -
Audience: The
audienceparameter in the token request must match the audience configured in your Harbor Federated Identity Provider (typically your registry domain). -
Username: The username for
docker loginis not used for authentication (can be any value likenot-relevant). Authentication is based solely on the JWT token. -
Token Claims: GitHub Actions OIDC tokens include claims such as:
iss: Issuer (alwayshttps://token.actions.githubusercontent.com)aud: Audience (your registry domain)sub: Subject (e.g.,repo:owner/repo:ref:refs/heads/main)repository: Repository namerepository_owner: Repository owneractor: User who triggered the workflowref: Git referencesha: Commit SHA
To inspect the JWT token contents during workflow execution:
- name: Debug JWT Token
run: |
echo "=== JWT Header ==="
echo "$TOKEN" | cut -d'.' -f1 | base64 -d 2>/dev/null | jq .
echo ""
echo "=== JWT Payload ==="
echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .Here's an example of what a GitHub Actions OIDC token looks like:
JWT Header:
{
"alg": "RS256",
"kid": "38826b17-6a30-5f9b-b169-8beb8202f723",
"typ": "JWT",
"x5t": "ykNaY4qM_ta4k2TgZOCEYLkcYlA"
}JWT Payload:
{
"actor": "Vad1mo",
"actor_id": "1492007",
"aud": "macfly4200.8gears.ch",
"base_ref": "",
"check_run_id": "56363837828",
"event_name": "workflow_dispatch",
"exp": 1764090768,
"head_ref": "",
"iat": 1764090468,
"iss": "https://token.actions.githubusercontent.com",
"job_workflow_ref": "container-registry/federated-idp-examples/.github/workflows/example_1.yml@refs/heads/main",
"job_workflow_sha": "15a5ebfa3fb5ddf10c4b4250e14496bec7f03a56",
"jti": "6cf8862b-b832-4372-9998-22026ecd21a1",
"nbf": 1764090168,
"ref": "refs/heads/main",
"ref_protected": "false",
"ref_type": "branch",
"repository": "container-registry/federated-idp-examples",
"repository_id": "1104004353",
"repository_owner": "container-registry",
"repository_owner_id": "46576199",
"repository_visibility": "public",
"run_attempt": "1",
"run_id": "19677834613",
"run_number": "4",
"runner_environment": "github-hosted",
"sha": "15a5ebfa3fb5ddf10c4b4250e14496bec7f03a56",
"sub": "repo:container-registry/federated-idp-examples:ref:refs/heads/main",
"workflow": "Create Image and Push Using federated IDP",
"workflow_ref": "container-registry/federated-idp-examples/.github/workflows/example_1.yml@refs/heads/main",
"workflow_sha": "15a5ebfa3fb5ddf10c4b4250e14496bec7f03a56"
}Key claims for Harbor claim rules:
| Claim | Description | Example Value |
|---|---|---|
iss |
Token issuer | https://token.actions.githubusercontent.com |
aud |
Target audience (your registry) | macfly4200.8gears.ch |
sub |
Subject identifier | repo:container-registry/federated-idp-examples:ref:refs/heads/main |
repository |
Full repository name | container-registry/federated-idp-examples |
repository_owner |
Organization or user | container-registry |
ref |
Git reference | refs/heads/main |
actor |
User who triggered the workflow | Vad1mo |
This example demonstrates how to authenticate to Harbor from a GitLab CI pipeline using OIDC tokens.
-
Harbor Setup: Configure a Federated Identity Provider in Harbor:
- OpenID Configuration URL:
https://gitlab.com/.well-known/openid-configuration - JWKS URI: Automatically discovered
- Issuer: Automatically discovered
- OpenID Configuration URL:
-
Robot Account: Create a federated robot account in Harbor with claim rules matching your GitLab project:
iss:https://gitlab.comaud:<your-registry-domain>(e.g.,macfly4200.8gears.ch)project_path:<namespace>/<project>(e.g.,8gears/container-registry/harbor-workload-identity-federation)
stages:
- build
build-and-push:
stage: build
image: docker:latest
services:
- docker:dind
id_tokens:
ID_TOKEN:
aud: <your-registry-domain> # π audience claim matching your registry
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
script:
# Login to registry using JWT token
- echo "$ID_TOKEN" | docker login -u not-relevant --password-stdin <your-registry-domain>
# Build and push image
- docker build -t <your-registry-domain>/library/image:$CI_COMMIT_SHA .
- docker push <your-registry-domain>/library/image:$CI_COMMIT_SHA
rules:
- when: manual-
id_tokens: GitLab CI uses the
id_tokenskeyword to request OIDC tokens. The token is automatically available as$ID_TOKEN. -
Audience: The
audfield underid_tokensmust match the audience configured in your Harbor Federated Identity Provider. -
Username: The username for
docker loginis not used for authentication (can be any value likenot-relevant). Authentication is based solely on the JWT token. -
Token Claims: GitLab CI OIDC tokens include claims such as:
iss: Issuer (alwayshttps://gitlab.comfor gitlab.com)aud: Audience (your registry domain)sub: Subject (e.g.,project_path:8gears/container-registry/harbor-workload-identity-federation:ref_type:branch:ref:main)project_path: Full project pathnamespace_path: Group/namespace pathref: Git referenceuser_login: User who triggered the pipeline
Here's an example of what a GitLab CI OIDC token looks like:
JWT Header:
{
"kid": "4i3sFE7sxqNPOT7FdvcGA1ZVGGI_r-tsDXnEuYT4ZqE",
"typ": "JWT",
"alg": "RS256"
}** GitLab JWT Payload:**
{
"project_id": "76366029",
"project_path": "8gears/container-registry/harbor-workload-identity-federation",
"namespace_id": "1087575",
"namespace_path": "8gears/container-registry",
"user_id": "907142",
"user_login": "vad1mo",
"user_email": "vaba@gmail.com",
"user_access_level": "owner",
"pipeline_id": "2179265456",
"pipeline_source": "push",
"job_id": "12217223061",
"ref": "main",
"ref_type": "branch",
"ref_path": "refs/heads/main",
"ref_protected": "true",
"runner_id": 47561171,
"runner_environment": "self-hosted",
"sha": "de7a8b6e3892321ac9d2305f26332dcdc775f1c2",
"project_visibility": "public",
"ci_config_ref_uri": "gitlab.com/8gears/container-registry/harbor-workload-identity-federation//.gitlab-ci.yml@refs/heads/main",
"ci_config_sha": "de7a8b6e3892321ac9d2305f26332dcdc775f1c2",
"jti": "aeb1491b-8988-49b5-ada0-2e372973b18e",
"iat": 1764098080,
"nbf": 1764098075,
"exp": 1764101680,
"iss": "https://gitlab.com",
"sub": "project_path:8gears/container-registry/harbor-workload-identity-federation:ref_type:branch:ref:main",
"aud": "macfly4200.8gears.ch"
}Key claims for Harbor claim rules:
| Claim | Description | Example Value |
|---|---|---|
iss |
Token issuer | https://gitlab.com |
aud |
Target audience (your registry) | macfly4200.8gears.ch |
sub |
Subject identifier | project_path:8gears/container-registry/harbor-workload-identity-federation:ref_type:branch:ref:main |
project_path |
Full project path | 8gears/container-registry/harbor-workload-identity-federation |
namespace_path |
Group/namespace path | 8gears/container-registry |
ref |
Git reference | main |
user_login |
User who triggered the pipeline | vad1mo |
-
Token Request: The CI/CD platform (GitHub Actions, GitLab CI) provides an OIDC token to the job. GitHub uses environment variables and a curl request, while GitLab provides the token directly via
id_tokens. -
Token Validation: Harbor validates the JWT by:
- Verifying the signature using the provider's JWKS
- Checking the
iss(issuer) claim matches the configured provider - Validating
exp(expiration) andaud(audience) claims
-
Robot Account Matching: Harbor matches the token's claims against configured claim rules to identify the appropriate robot account.
-
Authorization: Once matched, the request is authorized based on the robot account's permissions.
This section describes how to set up a local k3s/k3d cluster with Kubernetes Image Credential Provider to pull images using Service Account tokens (Workload Identity Federation).
- Docker installed
- k3d installed (
brew install k3dor see k3d.io) - kubectl installed
- The
credential-provider-pluginbinary for your architecture (linux-amd64 or linux-arm64)
Create k3d-config.yaml:
apiVersion: k3d.io/v1alpha5
kind: Simple
metadata:
name: credential-provider-test
servers: 1
agents: 0
image: rancher/k3s:v1.34.2-k3s1
volumes:
# Mount the credential provider binary
- volume: /path/to/linux-arm64/credential-provider-plugin:/var/lib/rancher/credentialprovider/bin/credential-provider-plugin
nodeFilters:
- all
# Mount the credential provider config
- volume: /path/to/k8s_credential_provider_config.yaml:/var/lib/rancher/credentialprovider/config.yaml
nodeFilters:
- all
options:
k3s:
extraArgs:
- arg: --disable=traefik
nodeFilters:
- server:*
# Allow your registry as an audience for service account tokens
- arg: --kube-apiserver-arg=api-audiences=https://kubernetes.default.svc.cluster.local,<your-registry-domain>
nodeFilters:
- server:*
# Enable feature gates for credential providers with SA tokens
- arg: --kube-apiserver-arg=feature-gates=ServiceAccountNodeAudienceRestriction=true
nodeFilters:
- server:*
- arg: --kubelet-arg=feature-gates=KubeletServiceAccountTokenForCredentialProviders=true
nodeFilters:
- server:*Create k8s_credential_provider_config.yaml:
kind: CredentialProviderConfig
apiVersion: kubelet.config.k8s.io/v1
providers:
- name: credential-provider-plugin
apiVersion: credentialprovider.kubelet.k8s.io/v1
tokenAttributes:
requireServiceAccount: true
serviceAccountTokenAudience: "<your-registry-domain>"
cacheType: Token
matchImages:
- "<your-registry-domain>"
defaultCacheDuration: "1h"# Create the cluster
k3d cluster create --config k3d-config.yaml
# Get kubeconfig
k3d kubeconfig get credential-provider-test > kubeconfig.yaml
export KUBECONFIG=kubeconfig.yamlExport the cluster's JWKS keys for configuring Harbor's Federated IDP:
# Get the OIDC discovery document
kubectl get --raw /.well-known/openid-configuration | jq .
# Get the JWKS keys
kubectl get --raw /openid/v1/jwks | jq . > jwks.json- In Harbor, go to Administration β Robot Accounts β Federated Identity Providers
- Create a new Federated IDP with:
- Issuer:
https://kubernetes.default.svc.cluster.local - JWKS: Paste the contents of
jwks.json
- Issuer:
- Create a robot account with claim rules:
iss == https://kubernetes.default.svc.cluster.localaud == <your-registry-domain>kubernetes.io.namespace == <namespace>(optional, for namespace-scoped access)
# Create a service account
kubectl create serviceaccount test-pull
# Create a test pod with projected volume for the audience
kubectl apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
name: test-harbor
namespace: default
spec:
serviceAccountName: test-pull
containers:
- name: test-harbor
image: <your-registry-domain>/library/hello-world:latest
volumeMounts:
- name: harbor-token
mountPath: /var/run/secrets/harbor
readOnly: true
volumes:
- name: harbor-token
projected:
sources:
- serviceAccountToken:
audience: <your-registry-domain>
expirationSeconds: 3600
path: token
EOF-
Feature Gates: Both
ServiceAccountNodeAudienceRestriction(API server) andKubeletServiceAccountTokenForCredentialProviders(kubelet) must be enabled. -
Projected Volume: The pod MUST have a projected volume with the audience matching your registry. Without this, kubelet cannot request tokens for that audience.
-
Credential Provider Plugin: The
credential-provider-pluginbinary receives the SA token from kubelet and returns it as credentials for the registry. -
JWKS Rotation: If you recreate the cluster, new signing keys are generated. You must update Harbor's Federated IDP with the new JWKS.
A Kubernetes Service Account token for image pulls looks like:
{
"aud": ["macfly4200.8gears.ch"],
"exp": 1764255017,
"iat": 1764251417,
"iss": "https://kubernetes.default.svc.cluster.local",
"jti": "5e8572b6-4e27-4039-8fc5-dcdd85ade4e1",
"kubernetes.io": {
"namespace": "default",
"serviceaccount": {
"name": "test-pull",
"uid": "238dbbbe-4d7c-4efe-bd03-957c547c8daa"
}
},
"nbf": 1764251417,
"sub": "system:serviceaccount:default:test-pull"
}Key claims for Harbor claim rules:
| Claim | Description | Example Value |
|---|---|---|
iss |
Token issuer | https://kubernetes.default.svc.cluster.local |
aud |
Target audience (your registry) | macfly4200.8gears.ch |
sub |
Subject identifier | system:serviceaccount:default:test-pull |
kubernetes.io.namespace |
Kubernetes namespace | default |
kubernetes.io.serviceaccount.name |
Service account name | test-pull |
- Tokens are short-lived (typically 5-10 minutes)
- Each pipeline/workflow run gets a unique token
- Claims provide fine-grained control over which workflows can access which resources
- No secrets need to be stored in CI/CD settings
