Denied requested access to the resource is denied on push
Denied requested access to the resource is denied is the registry telling you it knows exactly who you are and the answer is still no, which is why logging in again never helps. The interesting part is that the same code covers a refusal about the image you sent, not only about the account that sent it.

What this error means
The login step says it succeeded, layers may upload, and the push stops on a 403. The line is short and carries no detail. What Docker prints is the registry error code followed by its default message, so the word denied appears once as the code and once inside the sentence. A runner on the containerd image store words it differently and mentions the repository possibly not existing, which is the same 403 wearing a longer sentence. We recorded no run for this page, and the block below is set out as the sources format each half.
--- push a tag under a namespace the token cannot write
The push refers to repository [docker.io/otherorg/api]
denied: requested access to the resource is denied
--- the same refusal on a runner using the containerd image store
push access denied, repository does not exist or may require authorization: authorization failedWhere each word in the line comes from
The registry defines an error code DENIED, pinned to HTTP 403, whose default message is the sentence "requested access to the resource is denied". The client renders an error by lowercasing the code and replacing underscores with spaces, then appending the message. So DENIED becomes the leading word "denied", the colon is the join, and the rest is the registry default. Nobody wrote the whole line; it is two halves that happen to fit.
That means the second half tells you nothing unless the registry replaced it. A registry that sets a custom message on the same code will show something specific in that position, and it is worth reading when it does, because several of those custom messages are not about your account at all.
Message the registry sends with DENIED | What it is actually refusing |
|---|---|
| "requested access to the resource is denied" | The default. The access controller said no and gave no reason |
| "unknown manifest class for " and a config media type | The manifest, because its config type is not one the policy recognizes |
| "registry does not allow " and a class | The manifest class, which is not in the configured allowed list |
| "repository not authorized for " and a class | This repository for that class, though the registry allows the class elsewhere |
Common causes
The tag names a namespace the credential does not own
Pushing otherorg/api while logged in as acme is refused no matter how broad the token is, and so is pushing to a single-name repository on Docker Hub, which resolves into the official library namespace. In our experience this accounts for most first-time failures in a new publish workflow, because the tag is often copied from a README.
The token authenticates and carries no write permission
A read-only access token is accepted, so the login step is green, and the write is refused. The giveaway is that the same job pulls from the same repository successfully moments earlier. Registries that use OAuth challenges attach insufficient_scope to this, which the client maps to the same DENIED code.
The repository does not exist and the registry will not create one
Some registries create a repository on first push and some refuse, and a refusal to create can surface as a denial rather than as a missing name. Amazon ECR is the clearest example and has its own page here, because it only creates during a push when a repository creation template matches the name.
A registry policy refused the manifest class
The least expected cause and the easiest to misdiagnose, because it wears the same 403. A registry with configured repository classes rejects a manifest whose config media type it cannot classify, or whose class it does not allow, and it sends DENIED for all of those. The message will not be the default sentence when this is what happened.
How to fix it
Derive the tag from the account that is logged in
Build the reference from the same context value the login uses, so a rename or a fork changes both together. This is the fix that stops the whole class rather than the instance, and it costs one step.
- id: img
run: echo "ref=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: docker push "${{ steps.img.outputs.ref }}:${{ github.sha }}"Give the job the package write permission it needs
- For GitHub Packages, grant
packages: writeon the job, not just at workflow level, if any job narrows it. - Check that the package itself lists the repository as having write access, which is separate from the token scope.
- Re-run the job rather than re-issuing the token: the refusal is about permission, not about freshness.
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7Prove the write separately from the build
Push a tiny image to the target repository as its own step. It takes seconds, it needs no build, and it converts a failure that arrives after a ten minute build into one that arrives at the start. Keep it in the workflow while you are sorting permissions out and delete it afterwards.
docker pull alpine:3
docker tag alpine:3 ghcr.io/acme/api:permission-probe
docker push ghcr.io/acme/api:permission-probeRead the message before assuming it is you
If the text after the colon is not the default sentence, the registry replaced it deliberately. A message naming a media type or a class is a policy refusal about the manifest, and changing credentials will not move it. Take that string to whoever runs the registry, or change what the exporter writes.
docker push ghcr.io/acme/api:1.4.2 2>&1 | tee push.log
grep -E "denied:" push.logRule the namespace out first, because it costs nothing
The single commonest cause in CI is a tag pointing somewhere the credential cannot write, and it is free to check. Print the reference and the account you logged in with, in the same step, and compare the namespace by eye. An organization rename, a fork, a copied workflow and a default that still says library all produce this.
Do it before you touch permissions. A token widened to fix a tag that was always wrong is a permanent mistake fixing a temporary one.
- name: Show what is being pushed and by whom
run: |
echo "reference: $IMAGE"
echo "logged in as: ${{ secrets.REGISTRY_USER }}"
docker buildx imagetools inspect "${IMAGE%%:*}:latest" >/dev/null 2>&1 \
&& echo "repository is readable" || echo "repository not readable either"When the 403 is about the image rather than the account
A registry configured with a repository class policy checks the manifest before it stores it. It reads the config media type out of the manifest, maps it to a class, and refuses anything it cannot classify or is not allowed to hold. All three of those refusals use the DENIED code, so they arrive as a 403 that looks like a permissions problem and is not.
The signal is that the message is not the default sentence. If your log shows a 403 whose text names a media type or a class, stop changing tokens: the registry is objecting to what you sent, and the fix is on the exporter or with whoever configured the policy.
Why no recorded run backs this page
Reproducing this honestly needs two identities: one that can authenticate and one that cannot write to a chosen repository. A recorded run would therefore have to publish a credential real enough for a registry to accept, which is not something to put in a public page whatever its scope. The alternative, pushing to a namespace nobody owns, records a refusal from a registry defending itself against strangers rather than the refusal a reader gets from their own registry.
The claims here do not need a run anyway. Which code carries which status, how the two halves of the line are joined, and which messages the policy check substitutes are all in source. That is what the page cites, and it is what tells a reader whether their 403 is about them or about their image.
How to prevent it
- Assemble the push reference from the same value the login step uses.
- Keep a permission probe step in any workflow that publishes to a new repository.
- Grant package write at the job level so a later job cannot silently narrow it.
- Treat a non-default message after the colon as a different failure entirely.
Frequently asked questions
Why does it say denied twice?
DENIED rendered in lowercase, and the sentence after the colon is that code default message, which happens to end in the same word. The client joins them with a colon and prints both. Only the part after the colon can vary, and it does when a registry sets its own message.My login step succeeded. How can access still be denied?
Can this ever mean the repository does not exist?
Is a denied push ever worth retrying?
Related guides
References
- distribution: DENIED, its message and its 403 status
- distribution: the resource policy check that refuses a manifest class
- containerd: the push path wording for an access denial
- GitHub docs: publishing container images to GitHub Packages
- Docker documentation
- Docker build cache
- GitHub Actions documentation