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.

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.
{"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.
$ 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"| Status | What ghcr.io is saying | Where the fix lives |
|---|---|---|
| 401 | You need a token, and here is where to get one. | Add a login step, or let the client fetch an anonymous token. |
| 403 DENIED | You have a token and it does not cover this scope. | Package visibility, or the scopes on the credential. |
| 429 TOOMANYREQUESTS | The identity behind this token is out of allowance. | Whose token it is, and how many pulls you make. |
| 200 then a failed layer | The 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
- Add a login step with the built-in
GITHUB_TOKENat the top of any job that pulls from ghcr.io, including jobs whose pulls are made by a tool rather than by you. - Give the job
packages: readpermission, because a workflow that narrows permissions elsewhere will otherwise hand the login a token that cannot read packages. - Put the login before the setup steps, not next to the pull, so tools that fetch artifacts during their own setup are covered too.
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.
- 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.
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.tarMake 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.
trivy image --debug --exit-code 1 --severity HIGH,CRITICAL myimage:latestWhat 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.
- 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:latestWhy 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: readexplicitly, 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?
Do I need to log in to pull a public image from ghcr.io?
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?
Is toomanyrequests the same as TOOMANYREQUESTS?
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
- distribution/distribution: the registered registry error codes, including TOOMANYREQUESTS
- GitHub Docs: working with the Container registry, and how to authenticate to it
- GitHub Docs: introduction to GitHub Packages, on the tokens each case needs
- OCI Distribution Specification: the error envelope and the token flow
- GitHub Actions documentation