Skip to content
Latchkey LogoLatchkey home

Docker failed to fetch oauth token during a build

Docker failed to fetch oauth token means the builder asked a registry for permission to pull your base image and was refused, before a single layer moved. The word in front of token is the diagnosis, and our recorded run shows it: the same Dockerfile printed anonymous token with no credential in the client config and oauth token once the script had written a deliberately fake credential into that config for the registry to reject.

Runner log: an anonymous token refusal, then an oauth token refusal from one Dockerfile
The recorded run: three builds of one Dockerfile. The only change between the first and the second is a fake credential written into the client config.
Diagram of the token exchange, where credentials enter it, and which word the builder prints
The builder asks the token endpoint before it asks for a manifest. Which word it prints tells you whether your login reached this build at all.

What this error means

A build fails within a second or two at the FROM line, or at a COPY --from that names an image, and no build step runs. The message wraps whatever the token endpoint answered, so the useful parts are scattered: the word before "token", the registry host in the URL, and the status code at the end. Our recorded run put three builds on one runner to separate them, and turned up a case nobody documents: the host in the error is not always the host in the Dockerfile.

Actions log, buildx v0.36.1, two of three builds on one runner
--- no credential in the client config
ERROR: failed to build: failed to solve: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Alatchkeyci%2Fprivate-base-does-not-exist-9f3c1a%3Apull&service=ghcr.io: 403 Forbidden
--- same build, now with a credential the registry rejects (ghcr.io)
ERROR: failed to build: failed to solve: failed to fetch oauth token: denied: denied

Reproduced on a Latchkey runner

Run 2026-09-20·Runner latchkey-small·Exit code 1

Docker version 29.7.2, build a7dcaa6
github.com/docker/buildx v0.36.1 1d8dde89b8aba914e05e45366770736fea1fd690
--- no credential in the client config
ERROR: failed to build: failed to solve: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Alatchkeyci%2Fprivate-base-does-not-exist-9f3c1a%3Apull&service=ghcr.io: 403 Forbidden
--- writing a fake credential into ~/.docker/config.json
--- same build, now with a credential the registry rejects (ghcr.io)
ERROR: failed to build: failed to solve: failed to fetch oauth token: denied: denied
--- same build against Docker Hub, which reports the status code
ERROR: failed to build: failed to solve: latchkeyci/private-base-does-not-exist-9f3c1a:1.0: failed to resolve source metadata for docker.io/latchkeyci/private-base-does-not-exist-9f3c1a:1.0: unexpected status from HEAD request to https://179351861270.dkr.ecr.us-east-1.amazonaws.com/dockerhub/v2/latchkeyci/private-base-does-not-exist-9f3c1a/manifests/1.0?ns=docker.io: 401 Unauthorized
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=320s url=http://localhost/diagnose socket=/run/latchkey-self-heal/sock)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)

The runner diagnosed the failure and did not retry it; this failure needs the fix below.

Anonymous token and oauth token are different diagnoses

A registry pull is two requests: the builder asks a token endpoint for a token scoped to the repository it wants, then uses that token to ask for the manifest. This error is the first request failing, so nothing in your Dockerfile beyond the image name matters yet.

The distinction our run recorded is the part worth taking away. With no credential for that registry in the client configuration, the builder asks anonymously and reports an anonymous token failure. With a credential present and refused, it reports an oauth token failure. So the message answers a question you would otherwise spend an hour on: did the login step reach this build at all.

What the line saysWhat reached the builderWhere to look
failed to fetch anonymous tokenNo credential for that registryThe login step, its order, and its registry host
failed to fetch oauth tokenA credential the registry refusedThe token scope and the package permissions
A host you never wroteA registry mirror answereddocker info and the daemon configuration
A failure late in a long buildA token that expired mid-buildToken lifetime against build duration

Common causes

No credential for that registry reached the build

The anonymous wording, and the most common cause in CI. The login step is missing, it ran in a different job, it authenticated a different host than the image reference names, or it ran after the build. Our recorded run reproduces it exactly: an empty client configuration, a private-shaped reference, and a refusal from the token endpoint with no credential offered.

The credential is there and the registry refuses it

The oauth wording. A token with no read scope for that package, a workflow token in a repository the package has not granted access to, or a secret that expired all produce a login that looks fine followed by this. The registry says only that you are denied, which is deliberate: naming the reason would let anyone map private repositories.

The token expired during the build

Registry tokens are short lived, and a build that takes longer than the lifetime can start authorized and finish unauthorized, usually at the push or an export step rather than the FROM line. docker/buildx#3977 reports this shape against a cloud registry with a credential helper, where the same image pushes fine by hand immediately afterwards.

A mirror or proxy answered instead of the registry

Where a daemon has a registry mirror, the reference in your Dockerfile is resolved through another host, and that host has its own authentication. Our recorded run hit this on the Docker Hub build: the log quotes a pull-through cache host, not Docker Hub, so a Docker Hub credential would not have helped.

How to fix it

Put the login first, for the registry the image names

  1. Add the login step to the same job as the build, before the build step.
  2. Match the registry in the login to the host in the FROM line, not to the push target.
  3. Give the job the read permission for packages when using the built-in token.
.github/workflows/ci.yml
- uses: docker/login-action@v4
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7

Check the scope, not the login result

A successful login and a permitted pull are different facts. With the built-in token, the package has to grant this repository access; across organizations, use a token carrying the read:packages scope and store it as a secret rather than widening the workflow token.

.github/workflows/ci.yml
permissions:
  contents: read
  packages: read

Prove the reference by hand before changing the workflow

One pull from the runner, printed next to the reference the build used, separates a name problem from an access problem in a few seconds. If an unauthenticated pull works from a machine with no credentials, the image is public and your build is looking somewhere else.

Terminal
echo "base image: [$BASE]"
docker logout ghcr.io
docker pull "$BASE"   # anonymous, on purpose

Handle the expiring token as a duration problem

When the failure lands late in a long build rather than at the FROM line, the credential is not wrong, it is stale. Shorten the build, or split the push into a step that authenticates immediately before it runs.

.github/workflows/ci.yml
- uses: docker/build-push-action@v7
  with: { context: ., push: false, load: true }
- uses: docker/login-action@v4      # fresh, right before the push
  with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
- run: docker push ghcr.io/acme/api:${{ github.sha }}

Log in before the builder needs it, in the same job

A login writes a credential into the client configuration on that runner, and the builder reads it from there. That gives the ordering rule: the login has to come before the build in the same job, because a different job runs on a different runner with an empty configuration. It also gives the scope rule: the credential has to be for the registry in the image reference, not for the registry you push to.

For an image in GitHub Container Registry the built-in token is enough when the package grants this repository access. The GitHub documentation states that the workflow token can be used if the repository is granted read access to the package, and that a personal access token needs the read:packages scope to download container images, which is the fallback for a package that belongs to another organization.

.github/workflows/ci.yml
permissions:
  contents: read
  packages: read
steps:
  - uses: actions/checkout@v7
  - uses: docker/login-action@v4
    with:
      registry: ghcr.io
      username: ${{ github.actor }}
      password: ${{ secrets.GITHUB_TOKEN }}
  - uses: docker/setup-buildx-action@v4
  - uses: docker/build-push-action@v7
    with:
      context: .

The host in the error is not always the host in your Dockerfile

The third build in our recorded run aimed at Docker Hub and never got there. The log shows a request to an ECR pull-through cache host instead, answering 401 on a HEAD for the manifest, because the runner we used resolves Docker Hub references through a registry mirror. We expected a Docker Hub token endpoint in that line and got a mirror, which is worth disclosing rather than tidying away.

The lesson generalizes. Where a daemon has a mirror configured, a credential for the upstream registry is not one for the mirror, and the error names the host that refused you. Read that host before hunting for the wrong secret.

Terminal
docker info --format '{{.RegistryConfig.Mirrors}}'
# the error quotes the host that refused, not the one in your FROM line

What the runner does about it

No repair. On the recorded run the wrapper posted the failure to the sidecar, the sidecar answered, and the build ended on the same refusal. Latchkey carries no pattern for this, which is correct: a credential the job was never given is not something a runner should conjure, and retrying an authorization failure only spends the queue wait again.

The one case where a retry is right is the expiring token in docker/buildx#3977: the credential was valid at the start of the build and stale at the push. That is a shorter build or a fresher token, not a repair.

How to prevent it

  • Keep the login step and the build step in one job, login first.
  • Authenticate the registry in the FROM line, not only the push target.
  • Grant packages read at the job level rather than widening a token.
  • Print the base image reference and the registry mirrors once per job.

Frequently asked questions

What does failed to fetch oauth token mean in a docker build?
The builder asked the registry token endpoint for permission to pull the image and was refused while offering a credential. It happens before any layer is downloaded, so the image contents are not involved. The credential is present and not accepted for that repository, which points at the token scope or at package access rather than at the login itself.
Why does my build say failed to fetch anonymous token?
Because no credential for that registry was in the client configuration when the build ran, so the builder asked anonymously and the registry declined. On our recorded run this is what an empty configuration produced, and writing a credential into the configuration changed the same build to the oauth wording. Treat the anonymous wording as proof that your login did not reach this build.
Does docker login work for buildx builds?
Yes, when it runs before the build in the same job. The login writes the credential into the client configuration on that runner and the builder reads it from there, which is why a login in another job does nothing for this one. Match the registry host to the one in your image reference.
Why does the error name a registry I never used?
Because a registry mirror answered on behalf of the one you named. Our recorded run shows a Docker Hub reference resolving through a pull-through cache, with the mirror host in the error and a 401 from it. Check the daemon configuration with docker info before assuming the credential for the upstream registry was the one being refused.

Related guides

References

Your base image does not change every build. Latchkey keeps it on the runner between jobs. Start free → 30-day trial · No credit card