Skip to content
Latchkey LogoLatchkey home

ghcr toomanyrequests, and why anonymous still means a token

A ghcr toomanyrequests response is the OCI distribution error code for HTTP 429, returned by ghcr.io through the same error envelope every other registry uses, and it tells you that the identity attached to your request ran out of allowance. The part that surprises people is that a pull nobody logged in for still carries an identity of sorts, because ghcr.io issues a bearer token to anonymous clients too.

GHCR token exchange with the anonymous branch highlighted beside the 401 and 429 outcomes
Every ghcr.io pull is a two-step token exchange, including the ones nobody logged in for. The right-hand panel is the empty one: GitHub publishes no pull allowance for ghcr.io on any of the three articles that describe it.

What this error means

A step that pulls an image, or a tool that pulls something packaged as an OCI artifact, fails partway through with a code spelled TOOMANYREQUESTS in capitals, or with the lowercase toomanyrequests that Docker prints when it flattens a registry error into a daemon message. Trivy is the most common source of this in CI because it fetches its vulnerability database from ghcr.io as an OCI artifact on every scan, so the failure lands before any of your code is examined and reads like a scanner bug. The distinguishing feature against an authentication failure is the status: 429 rather than 401 or 403. A 401 arrives with a Www-Authenticate header telling you where to get a token, and a 429 does not, because you already had one.

Reconstructed: the envelope is verbatim from a ghcr.io response captured on 2026-09-21, the code and message are the registered pair in distribution/registry/api/errcode/register.go
{"errors":[{"code":"TOOMANYREQUESTS","message":"too many requests"}]}

What the wire actually shows

Asking ghcr.io for a manifest without credentials returns 401 and a header naming the token endpoint, the service and the scope you would need. Asking that token endpoint for a public repository returns a bearer token with no login involved. So there is no such thing as a tokenless pull from ghcr.io: an anonymous pull is a pull whose token was issued to nobody in particular, and whatever allowance applies to it is an allowance shared with everyone else in the same position.

The error envelope is the standard one. The response body below was captured from ghcr.io on 2026-09-21 by asking the token endpoint for a scope that is not available anonymously. Its code and message are character for character the Value and Message that ErrorCodeDenied registers in distribution/registry/api/errcode/register.go, which is worth more than it looks: it establishes that ghcr.io serves the upstream descriptors rather than its own wording, and so the TOOMANYREQUESTS pair is too many requests, registered against HTTP 429 in the same file.

Captured locally with curl against ghcr.io, 2026-09-21. Not runner output
$ curl -sS "https://ghcr.io/token?service=ghcr.io&scope=repository:homebrew/core:pull"
{"errors":[{"code":"DENIED","message":"requested access to the resource is denied"}]}

$ curl -sSI https://ghcr.io/v2/homebrew/core/manifests/latest | grep -i www-authenticate
www-authenticate: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:homebrew/core:pull"
StatusWhat ghcr.io is sayingWhere the fix lives
401You need a token, and here is where to get one.Add a login step, or let the client fetch an anonymous token.
403 DENIEDYou have a token and it does not cover this scope.Package visibility, or the scopes on the credential.
429 TOOMANYREQUESTSThe identity behind this token is out of allowance.Whose token it is, and how many pulls you make.
200 then a failed layerThe manifest was served and a blob was not.Usually the network, not the registry. Check the layer URL.

Common causes

The pull is anonymous, because nothing in the job logs in

A docker pull ghcr.io/... of a public image works without a login, so workflows are written that way and nobody notices until the shared allowance runs out. The tell is that the same image pulls fine from a laptop and fails on a runner, because the runner shares an egress address with a great many other jobs and the laptop does not.

A tool is pulling from ghcr.io without telling you it is a registry pull

Trivy fetches its vulnerability database as an OCI artifact from ghcr.io. So do several other scanners and policy tools. The failure therefore appears in a step that says nothing about Docker, with a message about a database rather than an image, and the registry error is buried a few lines down. In our experience this is the version that takes longest to diagnose, because nobody is looking at the step that pulls.

Every job in a matrix pulls the same artifact at the same moment

Twenty jobs starting together produce twenty identical manifest and blob requests within a second or two. Nothing about that is unreasonable in isolation, which is why it survives review, but it multiplies the request count of one pipeline by the width of its matrix, and the matrix is usually the thing that grew most recently.

Nothing is cached, so every run pays the full pull

A scanner database or a base image that is re-downloaded on every job turns a per-push cost into a per-job cost. Caching the artifact changes the arithmetic more than any retry setting can, because it removes requests instead of spreading them, and for a database that updates a few times a day it costs almost nothing in freshness.

How to fix it

Log in to ghcr.io before anything pulls from it

  1. Add a login step with the built-in GITHUB_TOKEN at the top of any job that pulls from ghcr.io, including jobs whose pulls are made by a tool rather than by you.
  2. Give the job packages: read permission, because a workflow that narrows permissions elsewhere will otherwise hand the login a token that cannot read packages.
  3. Put the login before the setup steps, not next to the pull, so tools that fetch artifacts during their own setup are covered too.
.github/workflows/ci.yml
permissions:
  contents: read
  packages: read

steps:
  - uses: docker/login-action@v3
    with:
      registry: ghcr.io
      username: ${{ github.actor }}
      password: ${{ secrets.GITHUB_TOKEN }}

Cache the artifact that is being pulled every run

For a scanner database, a cache keyed loosely enough to be reused across runs removes the majority of the requests. Restore keys matter more than the exact key here: you want yesterday's database when today's is not in the cache, rather than a miss that goes straight back to the registry.

.github/workflows/ci.yml
- uses: actions/cache@v4
  with:
    path: ~/.cache/trivy
    key: trivy-db-${{ github.run_id }}
    restore-keys: trivy-db-

Pull once per pipeline, not once per matrix job

Where a matrix all needs the same image, pull it in one job, save it as an artifact or push it into a local registry service, and have the matrix jobs load it. This turns a burst of twenty identical pulls into one, and it also makes the pipeline faster, which is an easier sell than the rate limiting argument.

.github/workflows/ci.yml
jobs:
  fetch-base:
    runs-on: ubuntu-latest
    steps:
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - run: |
          docker pull ghcr.io/${{ github.repository_owner }}/base:latest
          docker save ghcr.io/${{ github.repository_owner }}/base:latest -o base.tar
      - uses: actions/upload-artifact@v4
        with:
          name: base-image
          path: base.tar

Make the registry error visible where a tool swallows it

When the failing step is a scanner, the registry status is often several lines into a nested error. Raising the tool's verbosity once, in a scratch run, tells you whether you are looking at 401, 403 or 429, and those three need three different fixes. Guessing between them is how a rate limit gets treated as a credentials problem for a week.

Terminal
trivy image --debug --exit-code 1 --severity HIGH,CRITICAL myimage:latest

What GitHub publishes, and what it does not

GitHub's Container registry is a closed service, so the only defensible claims are the ones GitHub writes down and the bytes that come back. GitHub does publish that you need an access token to work with packages: the reusable text in its packages documentation says you need one "to publish, install, and delete private, internal, and public packages", and that in Actions you can use GITHUB_TOKEN for packages associated with the workflow repository, or a personal access token with at least read:packages for packages in other private repositories.

What GitHub does not publish, on the container registry article, the packages introduction or the installing-a-package article, is a number: no pulls per hour, no pulls per IP, no anonymous allowance. That absence matters because the internet fills it in, usually by copying Docker Hub's published figures across. Docker Hub documents its limits; those numbers are about Docker Hub. Any page that quotes a ghcr.io pull limit is quoting something GitHub has not stated, and this page is not going to join it.

The practical consequence is the same either way, and it does not need a number. An anonymous token shares whatever allowance exists with every other anonymous client on the same egress, and a workflow token does not. So the fix is to stop being anonymous, which is one step and costs nothing, rather than to tune around a limit whose size nobody outside GitHub knows.

.github/workflows/ci.yml
- uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

- run: docker pull ghcr.io/${{ github.repository_owner }}/base:latest

Why this page has no reproduction run

Reproducing a 429 from ghcr.io means deliberately exhausting a shared allowance on a service we do not own, from an address other people are using. That is a denial of service against strangers to produce a screenshot, and the screenshot would not even tell you the limit, because the response does not carry one.

What can be established without doing that is everything the page rests on: the token exchange, the error envelope, the registered code and message, and what GitHub has and has not published. Those were captured and read on 2026-09-21 and are quoted above. The one block this page cannot quote from a real response is the 429 body itself, so it is labelled as a reconstruction rather than dressed up as a capture, with the envelope marked as the part that is verbatim. content/heal-evidence.mjs has no record for this slug, so nothing here claims Latchkey repairs the failure.

How to prevent it

  • Log in to ghcr.io in every job that touches it, even when the image is public.
  • Grant packages: read explicitly, so a hardened permissions block does not silently break the login.
  • Cache any OCI artifact a tool re-downloads per job, with restore keys that allow a slightly stale hit.
  • Never copy another registry's published limits onto ghcr.io in a runbook. GitHub has not published one.

Frequently asked questions

What is the ghcr.io rate limit for anonymous pulls?
GitHub does not publish one. The container registry article, the packages introduction and the installing-a-package article all describe authentication and none of them states a pull allowance, and no number arrives in the 429 response either. Figures circulating for ghcr.io are usually Docker Hub's documented limits copied across. The actionable fact is that an anonymous token shares its allowance and a workflow token does not.
Do I need to log in to pull a public image from ghcr.io?
Not to make the pull work, but you should anyway in CI. A client with no credentials fetches an anonymous bearer token from https://ghcr.io/token and uses that, so the request is still counted against something, and that something is shared with every other anonymous client on your egress address. Logging in with GITHUB_TOKEN costs one step and moves you off the shared bucket.
Why does Trivy fail with TOOMANYREQUESTS before it scans anything?
Because Trivy fetches its vulnerability database from ghcr.io as an OCI artifact during startup, so a registry failure lands before any image layers are read. The step looks like a scanner problem and is actually a registry pull. Authenticating to ghcr.io in that job, and caching the database directory between runs, addresses both the allowance and the number of requests.
Is toomanyrequests the same as TOOMANYREQUESTS?
Yes. The registry error code is registered in upstream distribution as TOOMANYREQUESTS with the message too many requests and an HTTP status of 429, and Docker lowercases codes when it renders a registry error as a daemon message. When you search your logs or the web for one spelling only, you will miss half the reports.

Related guides

References

A 429 from ghcr.io is an identity problem, not a scanner bug. Latchkey repairs the transient failures around it. Start free → 30-day trial · No credit card