# Denied requested access to the resource is denied on push

> Fix denied requested access to the resource is denied on a CI push: one 403 for four situations, and only three are about your token.

Source: https://latchkey.dev/learn/docker/docker-denied-requested-access-to-resource-push  
Updated: 2026-09-20

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.

```Both wordings as their source files format them, not a recorded run
--- 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 failed
```

## 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.

```.github/workflows/publish.yml
- 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

1. For GitHub Packages, grant `packages: write` on the job, not just at workflow level, if any job narrows it.
2. Check that the package itself lists the repository as having write access, which is separate from the token scope.
3. Re-run the job rather than re-issuing the token: the refusal is about permission, not about freshness.

```.github/workflows/publish.yml
jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v7
```

### Prove 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.

```Terminal
docker pull alpine:3
docker tag alpine:3 ghcr.io/acme/api:permission-probe
docker push ghcr.io/acme/api:permission-probe
```

### Read 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.

```Terminal
docker push ghcr.io/acme/api:1.4.2 2>&1 | tee push.log
grep -E "denied:" push.log
```

## 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.

## Where 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 |

> The last three come from the resource policy check in the distribution manifest handler, read on 2026-09-20. That check returns immediately when no allowed classes are configured, so a default registry never sends them.

## Rule 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.

```.github/workflows/publish.yml
- 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.

## FAQ

### Why does it say denied twice?

It does not, quite. The first word is the error code `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?

Because those are separate questions. Logging in proves the registry could identify you, which it answers with a 401 when it cannot. Denial is a 403 and means it identified you and refused the specific operation on the specific repository. A read-only token produces exactly that pattern: a green login followed by a refused push.

### Can this ever mean the repository does not exist?

Yes, on registries that will not create one during a push. Telling an unauthorized client that a repository is absent would let anyone enumerate private names, so several registries answer both situations the same way. The containerd wording says so directly by mentioning that the repository may not exist or may require authorization.

### Is a denied push ever worth retrying?

No. A 403 is a decision the registry made after identifying you, and it will make the same decision on the next attempt within the same second. Retrying costs the layer upload again for an identical answer. The codes worth retrying are the 5xx range, and 429, which is about pace rather than permission.

## References

- [distribution: DENIED, its message and its 403 status](https://github.com/distribution/distribution/blob/main/registry/api/errcode/register.go)
- [distribution: the resource policy check that refuses a manifest class](https://github.com/distribution/distribution/blob/main/registry/handlers/manifests.go)
- [containerd: the push path wording for an access denial](https://github.com/containerd/containerd/blob/main/core/remotes/docker/pusher.go)
- [GitHub docs: publishing container images to GitHub Packages](https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
