Docker & ContainersXIII Β· RegistriesRegistry auth
Registry authentication β basic, token, OIDC
What you'll learn
- Describe the token exchange behind a docker pull and what each token is scoped to
- Locate every copy of a registry credential on a host and explain why base64 is not protection
- Configure a credential helper so the plaintext credential never reaches the filesystem
- Rotate a registry credential in an order that cannot break a running deploy
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12
Registries support three authentication schemes:
- Basic auth β username and password, sent over HTTPS.
- Bearer token β a short-lived, scope-limited token issued by an
auth service, passed as
Authorization: Bearer .... - OIDC β delegated authentication, where the registry trusts an external identity provider rather than holding a password itself.
In practice they are not alternatives. Basic credentials are usually what you exchange for a bearer token, and OIDC changes what you present at that exchange. Understanding the sequence is what lets you diagnose a 401 correctly.
Basic auth, and where the credential goes
docker login registry.example.com
# Username: deployer
# Password:
The fix is a credential helper
A credential helper is a small external program the Docker CLI calls
instead of writing the credential itself. The plaintext never touches
config.json.
{
"credsStore": "pass"
}Per-registry helpers are also supported, which matters when one registry authenticates through a cloud IAM helper and another through a local keyring:
{
"credsStore": "pass",
"credHelpers": {
"registry.example.com": "secretservice"
}
}On Linux the two common helpers are docker-credential-pass, which
stores secrets in a GPG-backed pass store, and
docker-credential-secretservice, which uses the D-Bus Secret Service
(GNOME Keyring and equivalents). Both are separate binaries you install
onto PATH; neither ships with the Docker CLI.
jq '.auths | map_values(has("auth"))' ~/.docker/config.json{
"registry.example.com": false
}Illustrative output
That is a verification that can fail, which is the point. βI configured
a credential helperβ is a claim; an empty auth field is evidence.
Never pass a password on the command line
docker login -u deployer -p "$DEPLOYER_PASSWORD" registry.example.com
This is the pattern every CI example uses and it is wrong in two ways: the value appears in the process list for as long as the command runs, and in the shell history of anyone who runs it interactively. Docker warns about it at runtime.
$ docker login -u deployer -p REDACTED registry.example.comWARNING! Using --password via the CLI is insecure. Use --password-stdin.Illustrative output
Use standard input instead. The documentationβs justification is exact:
βUsing STDIN prevents the password from ending up in the shellβs
history, or log-files.β
printf '%s' "$DEPLOYER_TOKEN" \
| docker login registry.example.com --username deployer --password-stdinLogin SucceededIllustrative output
Bearer tokens
Bearer tokens are the mechanism doing most of the work, and they are
invisible because docker login handles them.
The client presents its basic credential to an auth service, which
returns a token scoped to a specific action on a specific repository β
repository:library/nginx:pull, for example. That token, not the
password, is what accompanies the manifest and blob requests. It is
short-lived; the exact lifetime is set by the registry operator, and
clients simply request a new one when the old one is refused.
Two operational consequences follow from the scoping:
- A leaked token is bounded. It grants the actions named in its scope, on the repositories named in its scope, until it expires. A leaked password is not bounded at all β it mints new tokens.
- A 401 on a
pushwith a workingpullis normal. The token was issued withpullscope because that is what the client asked for. The fix is a credential with push permission, not a network investigation.
OIDC and workload identity
OIDC moves the trust decision out of the registry. The registry stops holding a password and instead validates a token issued by an identity provider it trusts.
This is worth doing for three reasons that have nothing to do with cryptography:
- Single sign-on for humans, so a departure revokes registry access as a side effect of revoking the account.
- Workload identity for machines β a CI job or a Kubernetes service account presents an ephemeral, automatically rotated token rather than a long-lived password somebody pasted into a secret store years ago.
- Auditing. The IdP logs the authentication with the real identity
attached, rather than the registry logging twelve pipelines all
presenting
svc-ci.
The third one is usually the argument that lands, because βwho pushed this imageβ is a question every incident eventually asks.
Service accounts, not people
For anything automated, the credential must belong to a robot:
- Scoped. Read-only for deploy hosts; push only for the CI job that publishes. A deploy host that can push is a deploy host that can poison the registry after one compromise.
- Per-consumer. One credential per pipeline, not one shared across the estate. Shared credentials cannot be rotated without a synchronised outage and cannot be attributed in an audit log.
- Named for its purpose.
svc-ci-publishtells the next operator what breaks if they disable it.deployer2does not.
Personal credentials in automation fail in a specific and predictable way: someone leaves, the account is disabled on their last day, and a pipeline nobody associated with that person stops working at the next run.
Rotation without an outage
- Issue the new credential alongside the old one. Both valid.
- Verify the new one works against the registry directly, before distributing it: a
docker loginplus a pull of a known digest from a scratch host. - Distribute it to every consumer β secret manager, CI variables, host config, Kubernetes pull secrets, the pull-through cache.
- Prove no consumer is still using the old one. This is the step people skip, and it is the only one that makes the next step safe.
- Disable the old credential β do not delete it yet.
- Wait one full cycle of your slowest consumer (a nightly job, a weekly DR test) before deleting.
Step 4 needs evidence, and where you get it depends on the registry. Harbor, Quay, GitLab and the cloud registries all record the authenticating identity in an access log; a Distribution registry behind a reverse proxy records it in the proxyβs access log. Filter for the old identity and confirm the last entry predates your distribution step.
LOGFILE=/var/log/nginx/registry-access.log
OLD_USER=deployer
grep -F "$OLD_USER" "$LOGFILE" | tail -5 || echo 'no recent use'no recent useIllustrative output
Knowledge check
Knowledge check Β· 5 questions
Q1. With no credential store configured, how is a registry password protected in `~/.docker/config.json`?
Q2. You run `docker login registry.example.com` successfully, then `sudo docker pull registry.example.com/api:1.4.0` fails with 401. Why?
Q3. Which practices reduce the blast radius of a registry credential? Select all that apply.
Q4. The correct first step when rotating a registry credential is to revoke the old one, so the exposure window closes immediately.
Q5. The bearer token used for a `docker pull` is scoped to a specific action on a specific repository, so a leaked token grants less than a leaked password.
Passing score: 75%. Answers are checked in this browser.