From August 2024 until 30 September 2026, converting a Docker Hub personal account into an organization quietly handed the account's personal access tokens to whoever was named as the new owner, without changing the token secrets. Docker's advisory for CVE-2026-105445 says an attacker could use this to get a working token that authenticated as someone else and could push images to their repositories. Docker fixed it within hours of the report, found no evidence it was exploited, emailed the people affected on 2 October and deactivated the transferred tokens on 8 October.
If you didn't get that email, Docker says you weren't exposed. But the bug is a good prompt to look at how your team signs in to Docker Hub, because most of it probably runs on long-lived personal tokens. This guide is for teams that publish or pull private images on Docker Hub. It covers what the flaw was, how to audit the tokens you have, how to find every pipeline that uses one, and how to move CI to organization tokens or OIDC, which needs no stored token at all.
The short answer
Check whether you received Docker's 2 October email; if not, you weren't affected. Then review your personal access tokens under Account settings, delete unused ones and recreate the rest with an expiry date and the lowest permission. Move automation off personal tokens: GitHub Actions workflows can sign in with OIDC and store no token, and other systems should use organization access tokens scoped to the repositories they need. Docker gives the same advice at the end of its advisory.
What CVE-2026-105445 was
Docker Hub lets you convert a personal account into an organization, mostly so that an image like alice/app can move to an organization without breaking every docker pull that references it. During conversion you name an existing account as the new organization's owner.
According to Docker's advisory, from 26 August 2024 conversion also reassigned the converted account's personal access tokens (PATs) to that new owner. It did this by changing the token's owner record, without rotating or revoking the secret, and the named owner wasn't asked or told. So an attacker could create an account, generate a PAT, convert the account and name a victim as owner. The attacker still held the secret, and the token now authenticated to the registry as the victim, with the token's scope, in the victim's repositories and in organizations the victim could reach.
What it couldn't do matters too. The token worked at the registry level only: no web interface access, and no way to change the victim's password, email or two-factor settings.
| CVE | CVE-2026-105445 (Docker CNA, CVSS 4.0 9.4) |
| Exposure window | 26 August 2024, 20:33 UTC, to 30 September 2026, about 19:10 UTC |
| Reported by | Peter Molnar and Krisztian Hozak (mptrdev.com), through Docker's disclosure program |
| Fix | Deployed 30 September 2026, under four hours after the report; conversion no longer transfers PATs |
| Investigation | Docker reviewed about 304,000 pushes made with transferred tokens and found no evidence of exploitation |
| Follow-up | Affected users emailed 2 October; transferred PATs deactivated 8 October unless owners opted out |
Docker is also open about the limit of that investigation: registry logs show when a token is used, not that it exists, so a transferred token that was never used leaves no trace. That's why it treated every transferred token as potentially affected. Docker says it's now asking for consent before someone is named as an organization owner, recording every change of token ownership, and reviewing other flows that change who owns a credential.
Step 1: audit your personal access tokens
In Docker Home, open Account settings and then Personal access tokens. For each token the list shows its description, scope, status, whether it's manual or auto-generated, when it was created, when it was last used and when it expires. A few facts from Docker's documentation shape what to do with that list:
- The default expiry is none. Unless someone chose 30 days, 90 days or a custom date of up to a year, the token never expires. You can't add an expiry to an existing token. You have to create a new one.
- A PAT's permission applies to every repository your account can access. You can't limit a PAT to one repository. The levels are Repo Public Read-only (the default), Repo Read-only, Repo Read & Write, and Repo Read, Write, Delete.
- Docker Desktop creates tokens for you. Signing in to Docker Desktop creates "Auto-generated" PATs for the CLI, up to five per account.
So: delete tokens that haven't been used recently, delete any you don't recognise, and replace long-lived tokens that are still needed with ones that expire, at the lowest permission that works. A CI job that only pulls a private base image needs Repo Read-only, not Read & Write.
Step 2: find every pipeline that signs in to Docker Hub
The hard part of rotating a token is finding everything that uses it. This script walks your repositories and reports every Docker Hub sign-in in GitHub Actions workflows, GitLab CI files, Jenkinsfiles and shell scripts. It marks each one as a stored secret (SECRET), OIDC, or REVIEW for wrapper actions and other steps handed a Docker Hub secret. It ignores logins to other registries such as GHCR or Quay. It only reads files and uses only the standard library.
#!/usr/bin/env python3
"""Find every CI step that signs in to Docker Hub, and say how: a stored secret (PAT or OAT)
or a short-lived OIDC token. Scans GitHub Actions workflows, GitLab CI files, Jenkinsfiles and
shell scripts under the given directories. Standard library only; it reads files, nothing else.
Usage: python3 dockerhub_logins.py path/to/repo [more/repos ...]"""
import os, re, sys
OTHER_REGISTRY = re.compile(r"registry:\s*['\"]?(?!docker\.io|registry-1\.docker\.io|index\.docker\.io)[\w.-]+\.[\w.-]+")
CLI_LOGIN = re.compile(r"docker\s+login\b(?P<args>[^\n|;&]*)")
def step_block(lines, i):
"""(first line number, lines) of the YAML list item that contains line i."""
start = i
while start > 0 and not lines[start].lstrip().startswith("- "):
start -= 1
indent = len(lines[start]) - len(lines[start].lstrip())
end = start + 1
while end < len(lines) and (not lines[end].strip() or len(lines[end]) - len(lines[end].lstrip()) > indent):
end += 1
return start, "\n".join(lines[start:end])
def scan(path):
text = open(path, errors="replace").read()
lines = text.splitlines()
seen_steps = set()
for i, line in enumerate(lines):
# Other actions handed a Docker Hub secret, or wrapper actions that log in for you
secret = re.search(r"\$\{\{\s*secrets\.(\w*docker\w*)", line, re.I)
wrapper = re.search(r"uses:\s*['\"]?([^'\"\s]*docker-?hub[^'\"\s]*)", line, re.I)
start, block = step_block(lines, i)
if (secret or wrapper) and "docker/login-action" not in block:
if start not in seen_steps:
seen_steps.add(start)
uses = re.search(r"uses:\s*['\"]?([^'\"\s]+)", block)
what = uses.group(1).split("@")[0] if uses else "run step"
detail = f"secrets.{secret.group(1)}" if secret else "wrapper action, check how it signs in"
yield i + 1, "REVIEW", f"{what}: {detail}"
if "docker/login-action" in line:
if OTHER_REGISTRY.search(block):
continue # GHCR, ECR, ACR and friends
if "DOCKERHUB_OIDC_CONNECTIONID" in block and "password:" not in block:
yield i + 1, "OIDC", "docker/login-action, short-lived token"
else:
secret = re.search(r"password:\s*\$\{\{\s*secrets\.(\w+)", block)
yield i + 1, "SECRET", f"docker/login-action, password from secrets.{secret.group(1) if secret else '?'}"
m = CLI_LOGIN.search(line)
if m and not line.lstrip().startswith("#"):
args = m.group("args").split()
server, skip = None, False
for a in args:
if skip:
skip = False
elif a in ("-u", "--username", "-p", "--password"):
skip = True
elif not a.startswith("-"):
server = a
if server and not re.match(r"(https?://)?(docker\.io|registry-1\.docker\.io|index\.docker\.io)", server):
continue # another registry
how = "password on command line" if {"-p", "--password"} & set(args) else "password from stdin or prompt"
yield i + 1, "SECRET", f"docker login ({how})"
CI_FILES = re.compile(r"(\.github/workflows/.+\.ya?ml|\.gitlab-ci\.yml|Jenkinsfile|\.sh)$")
found = {"SECRET": 0, "OIDC": 0, "REVIEW": 0}
for root_dir in sys.argv[1:]:
for dirpath, dirs, files in os.walk(root_dir):
dirs[:] = [d for d in dirs if d not in (".git", "node_modules", "vendor")]
for f in files:
path = os.path.join(dirpath, f)
if CI_FILES.search(path.replace(os.sep, "/")):
for line_no, kind, detail in scan(path):
found[kind] += 1
print(f"{kind:<7} {os.path.relpath(path, root_dir)}:{line_no} {detail}")
print(f"\n{found['SECRET']} Docker Hub login(s) use a stored secret, {found['OIDC']} use OIDC, "
f"{found['REVIEW']} other step(s) handle Docker Hub credentials.")
We tested it against a small fixture repository with three kinds of login. One was a workflow using docker/login-action with a stored token:
name: release
on: { push: { tags: ["v*"] } }
jobs:
push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
The others were a nightly workflow using OIDC and a shell script that logs in to Docker Hub and Quay:
$ python3 dockerhub_logins.py fixture
SECRET scripts/publish.sh:2 docker login (password from stdin or prompt)
SECRET scripts/publish.sh:3 docker login (password on command line)
SECRET .github/workflows/release.yml:9 docker/login-action, password from secrets.DOCKERHUB_TOKEN
OIDC .github/workflows/nightly.yml:11 docker/login-action, short-lived token
3 Docker Hub login(s) use a stored secret, 1 use OIDC, 0 other step(s) handle Docker Hub credentials.
The GHCR step in the workflow and the Quay login in the script are skipped, as they should be. We also ran it on the workflow files of four large open-source projects that publish images to Docker Hub, 77 files in all. It found 2 direct logins with a stored token, 25 steps that pass Docker Hub credentials to other actions or use a wrapper action that signs in for them, and no OIDC logins yet, which isn't surprising for a feature that only arrived in July. The REVIEW lines are where you'll need to read the wrapper action's own source to see what it stores.
Step 3: move GitHub Actions to OIDC
Docker Hub now supports OIDC connections for GitHub Actions, and docker/login-action has supported them since version 4.5.0 in July 2026. The workflow exchanges GitHub's short-lived identity token for a Docker access token on each run, so there's no long-lived credential to store, rotate or leak. The requirements:
- a Docker Team or Docker Business subscription, and an organization owner or editor to create the connection under Identity & auth, then OIDC connections,
- github.com repositories only, because GitHub Enterprise Cloud and Enterprise Server aren't supported yet,
docker/login-action4.5.0 or later (released 23 July 2026), withid-token: writepermission,- the organization name as the username, because only organization accounts can sign in this way.
The workflow change is small. You add the permission, drop the password and pass the connection ID:
permissions:
contents: read
id-token: write
jobs:
push:
runs-on: ubuntu-latest
steps:
- name: Docker login
uses: docker/login-action@v4 # 4.5.0 or later
env:
DOCKERHUB_OIDC_CONNECTIONID: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }}
with:
username: ${{ vars.DOCKERHUB_ORGANIZATION }}
The connection's rulesets match subject claims in GitHub's token, so you can limit which repositories can sign in. That's a better control than a shared secret that any workflow in the repository can read. Once a pipeline works with OIDC, delete the DOCKERHUB_TOKEN secret it used.
Step 4: use organization access tokens everywhere else
For automation outside GitHub Actions, such as GitLab CI, Jenkins, deployment systems and monitoring tools, Docker recommends organization access tokens (OATs). They differ from PATs in the ways that matter here:
- They belong to the organization, not a person, so they keep working when people leave and any organization owner can see and revoke them.
- They can be scoped per repository, up to 50 repositories each with their own scopes, instead of covering everything the account can reach.
- You sign in with the organization name as the username, not a person's Docker ID.
They also have limits. OATs need a Team or Business subscription, they don't work with Docker Desktop or Image Access Management, and organizations get up to 10 (Team) or 100 (Business). Like PATs, they default to no expiry, so set one when you create them.
Keep PATs for people (our guide to personal access token hygiene covers the general practices): the Docker Desktop and CLI sign-ins that act as an individual, with an expiry date and the lowest permission each person needs.
The fair view
Docker handled this well. It fixed the flaw within four hours of the report, published a clear write-up with a timeline, explained the limits of its own investigation, and treated every transferred token as exposed instead of relying on detection. If you didn't get the email, you have nothing to clean up from this incident.
The general lesson is about where tokens live, not about Docker. A personal token that can push to every repository you can reach, never expires and sits in CI secrets is a standing risk whether or not a platform bug ever moves it. Docker now offers ways to avoid most of those tokens. Using them takes an afternoon, and the scan above shows you where to start.