Docker Hub OIDC for GitHub Actions: Retire Your Stored Access Tokens
Docker Hub OIDC for GitHub Actions: Retire Your Stored Access Tokens
If your CI pipeline pushes images to Docker Hub, there's a good chance a Personal Access Token (PAT) or Organization Access Token (OAT) is sitting in your GitHub repository secrets right now β valid for months, scoped broader than you'd like, and one leaked build log away from being someone else's problem. On July 31, 2026, Docker shipped OIDC connections for GitHub Actions, closing that gap the same way AWS and GCP closed it for cloud credentials years ago: workflows authenticate with a token that's minted per run and expires in minutes, so there's nothing sitting in secrets to steal or rotate.
Why this matters
PATs and OATs are static credentials. Once one lands in DOCKERHUB_TOKEN, it's valid until someone remembers to revoke it β which in practice means it outlives the person who created it, the pipeline that used it, and often the repo itself. If it leaks (a misconfigured log, a compromised dependency, a forked PR workflow), it's usable by anyone who finds it until you notice and rotate.
OIDC removes the static credential entirely. GitHub issues a signed JWT scoped to the specific workflow run; Docker verifies it against GitHub's public key registry, checks it against a ruleset you define, and hands back an access token that's dead within minutes. There's no secret to exfiltrate because the credential doesn't exist until the run starts and doesn't exist anymore shortly after it ends.
How the token exchange works
- GitHub issues a signed OIDC token encoding the repo, branch, environment, and workflow run.
- Your workflow calls
docker/login-action, presenting that token. - Docker verifies the signature and matches the token's subject claim against a ruleset configured in Docker Home.
- If it matches, Docker returns a short-lived access token scoped to whatever that ruleset allows.
docker/login-actionuses that token to authenticate β everything downstream (docker push,docker pull) works exactly as it does today.
Setting it up
Prerequisites: this is available on Docker Team, Docker Business, Docker Hardened Images (DHI), and organizations enrolled in the Docker Sponsored Open Source Program (DSOS). If you're on a free/personal Docker Hub plan, OIDC connections aren't exposed in Docker Home yet β keep using a scoped PAT.
1. Create the OIDC connection in Docker Home
In Docker Home, open your organization, go to OIDC connections, and create a new connection. This is where you define rulesets β the subject-claim patterns that decide which repos and branches are allowed to authenticate:
1repo:my-org/my-repo:ref:refs/heads/main # only main, exact repo
2repo:my-org/my-repo:ref:refs/heads/release-* # release branches, wildcard
3repo:my-org/my-repo:* # any ref in that repo
4repo:my-org/* # any repo in the org (avoid this)
You get up to 5 rulesets per connection. Scope tightly β repo:my-org/* defeats most of the point, since any repo in the org (including a new one someone spins up next week) inherits push access.
One thing worth knowing before you write rulesets: GitHub repositories created after July 15, 2026 use immutable numeric identifiers in their subject claim instead of the plain org/repo name:
1repo:octocat@123456/my-repo@456789:ref:refs/heads/main
Older repos still emit the name-based claim. Check the "Failures" tab on the OIDC connection page after a test run β it shows you the exact sub value GitHub sent, so you're not guessing at the format.
Copy the connection ID once it's created; you'll need it in the workflow.
2. Update the workflow
1name: build-and-push
2
3on:
4 push:
5 branches: [main]
6
7permissions:
8 contents: read
9 id-token: write # required β this is what lets the workflow request an OIDC token at all
10
11jobs:
12 push:
13 runs-on: ubuntu-latest
14 steps:
15 - uses: actions/checkout@v5
16
17 - name: Login to Docker Hub
18 uses: docker/login-action@v4 # v4.5.0 or newer
19 env:
20 DOCKERHUB_OIDC_CONNECTIONID: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }}
21 with:
22 username: ${{ vars.DOCKERHUB_ORGANIZATION }}
23 # no password field β that's the whole point
24
25 - name: Build and push
26 run: |
27 docker build -t myorg/myimage:${{ github.sha }} .
28 docker push myorg/myimage:${{ github.sha }}
Two details that trip people up the first time:
id-token: writehas to be set explicitly at the workflow or job level. Without it, GitHub won't issue an OIDC token and the login step fails with an auth error that looks like a bad credential, not a missing permission.usernameis your Docker Hub organization name, not a personal username or service account β the ruleset is evaluated against the org-level connection, not an individual account.
There's also DOCKERHUB_OIDC_EXPIREIN, an optional env var to control how long the minted token lives (300β21600 seconds, default 300). Leave it at the default unless a specific job legitimately runs long enough to need more than 5 minutes between login and the last docker push.
3. Verify, then remove the old token
Run the workflow. Check the OIDC connection's activity log in Docker Home to confirm the exchange succeeded and matched the ruleset you expected β not a broader one you forgot was still active. Once you've confirmed a few successful runs, delete the PAT/OAT from GitHub repo secrets. Don't leave it as a "just in case" fallback; an unused-but-valid token is the exact liability you just removed.
Best practices
- Scope rulesets to exact repo + branch, not org-wide wildcards. A ruleset that matches
repo:my-org/*gives every current and future repo in the org push access to whatever that ruleset permits. - Migrate high-value pipelines first β anything pushing to a widely-pulled base image or a production-tagged repo. That's where a leaked static token does the most damage.
- Don't mix OIDC and a stored token on the same workflow as a permanent setup. If both are present "just in case," you haven't removed the attack surface, you've doubled it.
- Audit the Failures tab periodically even after migration β a ruleset that stops matching (say, after a repo rename) fails loudly in CI, but it's worth knowing why before someone quietly adds the old PAT back to unblock a build.
Common mistakes
- Forgetting
id-token: write. This is the single most common failure. The error surfaces as a login/auth failure, which sends people looking at the connection ID or org name first instead of the missing permission. - Using a personal account name instead of the org name in
username. OIDC connections are organization-scoped; a personal Docker Hub username won't match any ruleset. - Writing rulesets against the wrong subject-claim format. If your repo was created after July 15, 2026, its claim uses the numeric
org@id/repo@idform, not the plain name. Check the Failures tab's capturedsubvalue before assuming your ruleset syntax is wrong. - Assuming this replaces PATs everywhere. OIDC here is GitHub Actionsβspecific. Local
docker loginon a laptop, other CI providers, and non-Actions automation still need a PAT (ideally a narrowly scoped one) β this doesn't eliminate PATs from your org, just from the CI path where they're riskiest.
Troubleshooting
Login step fails with an authentication error, everything else looks right. Check permissions: id-token: write is actually present on the job (not just contents: read). This is the most common cause by a wide margin.
Ruleset doesn't match, connection log shows a different sub than expected. Compare the captured claim against your ruleset pattern character-for-character β this is almost always the immutable-ID format catching people who wrote rulesets assuming the old org/repo name format.
Works on main but fails on a feature branch. Your ruleset is branch-scoped (as it should be) and doesn't include that branch. Either widen the ruleset intentionally or don't β the failure is the ruleset working as designed if that branch shouldn't have push access.
FAQ
Does this replace Docker Hub PATs entirely? No. It replaces PATs/OATs specifically for the GitHub Actions login path. Local development, non-GitHub CI, and any automation outside Actions still authenticate with a PAT.
What plans have access to OIDC connections? Docker Team, Docker Business, Docker Hardened Images, and organizations enrolled in the Docker Sponsored Open Source Program. It isn't available on free/personal accounts as of this writing.
How long does the minted access token live?
Between 300 and 21,600 seconds (5 minutes to 6 hours), controlled by DOCKERHUB_OIDC_EXPIREIN, defaulting to 5 minutes.
Does GitLab CI or another CI provider get this too? Not yet. Docker's announcement is explicitly scoped to GitHub Actions; the blog post notes other CI providers "may follow," with no committed date.
Can I run OIDC and a stored PAT side by side during migration? Yes, and you should β verify OIDC works on a given workflow before removing the fallback PAT for that specific repo. Just don't leave both in place indefinitely once OIDC is confirmed working.
Key takeaways
| Before (PAT/OAT) | After (OIDC) |
|---|---|
| Long-lived, stored in GitHub secrets | Minted per run, lives minutes |
| Manual rotation required | Nothing to rotate |
| Scope set once at token creation | Scope enforced per-run via ruleset match |
| Leak = valid until manually revoked | Leak = already expired by the time it's found |
| Works everywhere (laptop, any CI) | GitHub Actions only, for now |