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.34+ (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": "[email protected]",
"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 (KEP-4412) to pull images using Service Account tokens (Workload Identity Federation).
In Kubernetes 1.34+, the kubelet can automatically request Service Account tokens with custom audiences for image credential providers. This eliminates the need for static image pull secrets:
┌─────────────────────────────────────────────────────────────────────────────┐
│ k3d Cluster │
│ ┌─────────────────┐ ┌──────────────────┐ ┌───────────────────────┐ │
│ │ Pod (httpd) │ │ Kubelet │ │ Credential Provider │ │
│ │ │───▶│ │───▶│ Plugin │ │
│ │ Uses SA: default│ │ Requests SA token│ │ Returns Basic Auth │ │
│ └─────────────────┘ │ with audience │ │ (jwt:<SA token>) │ │
│ └──────────────────┘ └───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ containerd │ │
│ │ (pulls image) │ │
│ └────────┬─────────┘ │
└──────────────────────────────────┼──────────────────────────────────────────┘
│ Basic Auth: jwt:<k8s-sa-token>
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Harbor Registry │
│ ┌──────────────────┐ ┌─────────────────────────────────────────────┐ │
│ │ robotjwt │───▶│ Validates K8s JWT signature via JWKS │ │
│ │ middleware │ │ Maps claims to robot account permissions │ │
│ └──────────────────┘ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
- Docker installed
- k3d installed (
brew install k3dor see k3d.io) - kubectl installed
- The
credential-provider-pluginbinary for your architecture (linux-amd64 or linux-arm64)
# 1. Create the cluster
k3d cluster create --config k3d-config.yaml
# 2. Get kubeconfig
k3d kubeconfig get credential-provider-test > kubeconfig.yaml
export KUBECONFIG=kubeconfig.yaml
# 3. Apply RBAC for audience token requests
kubectl apply -f rbac-audience.yaml
# 4. Get JWKS for Harbor configuration
kubectl get --raw /openid/v1/jwks | jq .
# 5. Configure Harbor Federated IDP with the JWKS
# 6. Create robot account with claim rules
# 7. Deploy test pod
kubectl apply -f pod-example.yaml
kubectl get pod httpd -wapiVersion: 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 to k3s default path
- volume: /path/to/linux-arm64/credential-provider-plugin:/var/lib/rancher/credentialprovider/bin/credential-provider-echo-token-silly
nodeFilters:
- all
# Mount the credential provider config to k3s default path
- 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:*kind: CredentialProviderConfig
apiVersion: kubelet.config.k8s.io/v1
providers:
- name: credential-provider-echo-token-silly
apiVersion: credentialprovider.kubelet.k8s.io/v1
tokenAttributes:
requireServiceAccount: true
serviceAccountTokenAudience: "<your-registry-domain>"
cacheType: Token
matchImages:
- "<your-registry-domain>"
defaultCacheDuration: "1h"This RBAC configuration authorizes kubelets to request tokens with your registry's audience. Required by the ServiceAccountNodeAudienceRestriction feature gate (enabled by default in Kubernetes 1.34+).
# ClusterRole that allows requesting SA tokens with the harbor audience
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: node-harbor-audience-token
rules:
- verbs: ["request-serviceaccounts-token-audience"]
apiGroups: [""]
resources: ["<your-registry-domain>"]
---
# Bind to system:nodes group so all kubelets can use it
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: node-harbor-audience-token
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: node-harbor-audience-token
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: system:nodesWith KEP-4412, pods don't need projected volumes. The kubelet handles token generation automatically:
apiVersion: v1
kind: Pod
metadata:
name: httpd
namespace: default
spec:
serviceAccountName: default
containers:
- name: httpd
image: <your-registry-domain>/library/httpd
imagePullPolicy: Always-
Export JWKS from cluster:
kubectl get --raw /openid/v1/jwks | jq .
-
Create Federated IDP in Harbor:
- Issuer:
https://kubernetes.default.svc.cluster.local - Audience:
<your-registry-domain> - JWKS: Paste the JSON output
- Issuer:
-
Create Robot Account with claim rules:
subequalssystem:serviceaccount:default:default(specific SA)- Or
submatchessystem:serviceaccount:*:*(any SA) - Grant pull permission on target repository
{
"aud": ["macfly4200.8gears.ch"],
"exp": 1764290604,
"iat": 1764287004,
"iss": "https://kubernetes.default.svc.cluster.local",
"jti": "a8c1d9f4-ded3-42b8-9377-402f6cc34f5e",
"kubernetes.io": {
"namespace": "default",
"node": {
"name": "k3d-credential-provider-test-server-0",
"uid": "c922a6dd-d8af-4877-8a09-922898d7ddb5"
},
"pod": {
"name": "httpd",
"uid": "375b30f1-ef29-4477-b46a-aa80607b6cfc"
},
"serviceaccount": {
"name": "default",
"uid": "c871d5a5-d81d-4ce7-8c4b-2dd62dbde226"
}
},
"nbf": 1764287004,
"sub": "system:serviceaccount:default:default"
}Key claims for Harbor robot account rules:
| Claim | Description | Example Value |
|---|---|---|
iss |
Token issuer | https://kubernetes.default.svc.cluster.local |
aud |
Target audience | macfly4200.8gears.ch |
sub |
Subject identifier | system:serviceaccount:default:default |
kubernetes.io.namespace |
Kubernetes namespace | default |
kubernetes.io.serviceaccount.name |
Service account name | default |
kubernetes.io.pod.name |
Pod requesting the image | httpd |
-
No Projected Volumes Required: KEP-4412 handles token generation automatically. Pods don't need projected volumes.
-
RBAC for Audiences: The
request-serviceaccounts-token-audienceverb authorizes which audiences kubelets can request tokens for. -
JWKS Rotation: Recreating the cluster generates new signing keys. Update Harbor's Federated IDP with the new JWKS.
-
Kubernetes 1.33: Must explicitly enable feature gates
ServiceAccountNodeAudienceRestrictionandKubeletServiceAccountTokenForCredentialProviders.
| Error | Cause | Solution |
|---|---|---|
audience not found in pod spec volume |
Missing RBAC for audience | Apply rbac-audience.yaml |
no robots matched your token |
No robot with matching claims | Create robot with sub claim rule |
401 Unauthorized |
JWKS mismatch or wrong audience | Verify JWKS and audience in Harbor |
- 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
