Docker does not respect per-repo authn challenges if /v2/ returns 200
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 32/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Stale
- Tech stack
- docker, go
- Domain
- authentication, security
Research direction
Start by reproducing the behavior with the provided registry.go gist and the GET /v2/ plus HEAD and GET manifest requests, using overlay2 as described. Review the referenced docker/cli pull request to determine project ownership. Done means per-repository WWW-Authenticate challenges are honored without forcing credentials on repositories that do not require them.
Written by the indexing model from the issue text.
Description
Description
If docker login is used with a private registry that serves both unauthenticated and authenticated requests, depending on a repo's access control policy, docker pull does not respect the registry's fine-grained access control policies. This is because Docker (sometimes) uses the GET /v2/ endpoint to determine if a registry requires authentication.
- If Docker receives a
200from theGET /v2/endpoint, it will never pull credentials to authenticate with that registry, even when a particular request to a repo sends aWWW-Authenticateauthentication challenge. - If Docker receives a
401from theGET /v2/endpoint, it will pull credentials and provide them when it's presented with an authentication challenge. Docker will send credentials with every subsequent request to that registry, even if a particular repo does not send an authentication challenge.
Docker is making an assumption that a registry either requires authentication or it doesn't. But that's a rather large assumption. In the real world, repos will have a variety of access control policies, and Docker should appropriately respond to authentication challenges as it is presented with them.
The problem becomes apparent in a multi-tenant registry that has per-repo access control policies. There's no way of knowing what endpoint the client will request after GET /v2/ nor which tenant the subsequent request will be for, because there's no information in GET /v2/. Thus, relying on GET /v2/ for an accurate and global authentication challenge is impossible in the real-world.
This behavior is very confusing, and it was very hard to debug, because this behavior does not happen for everybody. At first I thought it was a misconfigured credential store, but eventually I was able to reproduce it on a brand new Linux server and I narrowed it down to the storage engines that Docker uses, overlay2 vs containerd.
By default, as far as I understand, Linux uses the graphdriver storage engine (overlay2), but Docker Desktop now defaults to using containerd. As we will see, there is a difference in behavior.
Reproduce
Below is a toy private registry that can be used to reproduce the behavior:
https://gist.github.com/ezekg/bc4f9afaac2ecb633113b494306fcd4a
Start the server with go run registry.go and make the following requests:
docker login localhost:3000 --username foo --password bar
docker pull localhost:3000/foo
docker pull localhost:3000/bar
(You can use ngrok or caddy to serve via TLS if you don't want to set localhost:3000 as an insecure registry.)
Note how the HEAD request to /foo fails, even though the server responded with a 401 status and an authentication challege, yet it continues to make a subsequent unauthenticated GET request:
2025/09/04 09:11:58 request: id=HaRnGLAj method="HEAD" path="/v2/foo/manifests/latest" auth=""
2025/09/04 09:11:58 response: id=HaRnGLAj status=401 bytes=0 challenges=["Basic realm=\"app\""]
2025/09/04 09:11:58 request: id=mMHDuYvm method="GET" path="/v2/foo/manifests/latest" auth=""
2025/09/04 09:11:58 response: id=mMHDuYvm status=200 bytes=0 challenges=["Basic realm=\"app\""]
When we make the request to /bar, it succeeds, since no authentication challenge was given:
2025/09/04 09:12:13 request: id=BpGuAWad method="HEAD" path="/v2/bar/manifests/latest" auth=""
2025/09/04 09:12:13 response: id=BpGuAWad status=200 bytes=0 challenges=[]
2025/09/04 09:12:13 request: id=BvMicvba method="GET" path="/v2/bar/manifests/latest" auth=""
2025/09/04 09:12:13 response: id=BvMicvba status=200 bytes=0 challenges=[]
If we update the /v2 route to respond with a 401 plus a challenge, the reverse happens — /foo succeeds, but then there are problems with /bar:
case "/v2", "/v2/":
res.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
+ if auth := req.Header.Get("Authorization"); !strings.HasPrefix(auth, "Basic ") {
+ res.Header().Set("WWW-Authenticate", `Basic realm="app"`)
+ res.WriteHeader(http.StatusUnauthorized)
+ return
+ }
res.WriteHeader(http.StatusOK)
return
docker login localhost:3000 --username foo --password bar
docker pull localhost:3000/foo
docker pull localhost:3000/bar
2025/09/04 09:43:23 serving on localhost:3000...
2025/09/04 09:43:30 request: id=Pegayvhl method="GET" path="/v2/" auth=""
2025/09/04 09:43:30 response: id=Pegayvhl status=401 bytes=0 challenges=["Basic realm=\"app\""]
2025/09/04 09:43:30 request: id=VHAYRlCC method="GET" path="/v2/" auth="Basic Zm9vOmJhcg=="
2025/09/04 09:43:30 response: id=VHAYRlCC status=200 bytes=0 challenges=[]
2025/09/04 09:43:32 request: id=mXmrgmVl method="GET" path="/v2/" auth=""
2025/09/04 09:43:32 response: id=mXmrgmVl status=401 bytes=0 challenges=["Basic realm=\"app\""]
2025/09/04 09:43:32 request: id=MPuqhjBd method="HEAD" path="/v2/foo/manifests/latest" auth="Basic Zm9vOmJhcg=="
2025/09/04 09:43:32 response: id=MPuqhjBd status=200 bytes=0 challenges=[]
2025/09/04 09:43:32 request: id=okEKrWqh method="GET" path="/v2/foo/manifests/latest" auth="Basic Zm9vOmJhcg=="
2025/09/04 09:43:32 response: id=okEKrWqh status=200 bytes=0 challenges=[]
2025/09/04 09:43:35 request: id=nvnEApia method="GET" path="/v2/" auth=""
2025/09/04 09:43:35 response: id=nvnEApia status=401 bytes=0 challenges=["Basic realm=\"app\""]
2025/09/04 09:43:35 request: id=IXxyZAqA method="HEAD" path="/v2/bar/manifests/latest" auth="Basic Zm9vOmJhcg=="
2025/09/04 09:43:35 response: id=IXxyZAqA status=200 bytes=0 challenges=[]
2025/09/04 09:43:35 request: id=CNEWFkCJ method="GET" path="/v2/bar/manifests/latest" auth="Basic Zm9vOmJhcg=="
2025/09/04 09:43:35 response: id=CNEWFkCJ status=200 bytes=0 challenges=[]
2025/09/04 09:44:07 request: id=bncAdRDB method="GET" path="/v2/" auth=""
2025/09/04 09:44:07 response: id=bncAdRDB status=401 bytes=0 challenges=["Basic realm=\"app\""]
Note however that requests to /bar are now sending authentication, even though its access control policy doesn't require it.
And if we logout, now /bar actually requires authentication, because it can't pass the GET /v2/ challenge:
docker logout localhost:3000
docker pull localhost:3000/bar
2025/09/04 09:54:25 request: id=XyzkbOYJ method="GET" path="/v2/" auth=""
2025/09/04 09:54:25 response: id=XyzkbOYJ status=401 bytes=0 challenges=["Basic realm=\"app\""]
2025/09/04 09:54:28 request: id=RmdrKBxB method="GET" path="/v2/" auth=""
2025/09/04 09:54:28 response: id=RmdrKBxB status=401 bytes=0 challenges=["Basic realm=\"app\""]
This is because, as far as I understand, the overlay2 storage engine that Docker uses by default, solely uses GET /v2/ to determine authentication requirements. It does not respect per-repo authentication challenges.
Expected behavior
Docker should respect and appropriately respond to authentication challenges as it is presented with them.
Reverting the changes above and switching to the containerd storage engine resolves the issue:
docker login localhost:3000 --username foo --password bar
docker pull localhost:3000/foo
docker pull localhost:3000/bar
2025/09/04 09:48:05 serving on localhost:3000...
2025/09/04 09:48:09 request: id=qUkKEtls method="GET" path="/v2/" auth=""
2025/09/04 09:48:09 response: id=qUkKEtls status=200 bytes=0 challenges=[]
2025/09/04 09:48:09 request: id=nlinzpGC method="GET" path="/v2/" auth=""
2025/09/04 09:48:09 response: id=nlinzpGC status=200 bytes=0 challenges=[]
2025/09/04 09:48:12 request: id=xOUrhDAN method="HEAD" path="/v2/foo/manifests/latest" auth=""
2025/09/04 09:48:12 response: id=xOUrhDAN status=401 bytes=0 challenges=["Basic realm=\"app\""]
2025/09/04 09:48:12 request: id=hnZJyIsB method="HEAD" path="/v2/foo/manifests/latest" auth="Basic Zm9vOmJhcg=="
2025/09/04 09:48:12 response: id=hnZJyIsB status=200 bytes=0 challenges=[]
2025/09/04 09:48:12 request: id=kPeRusEu method="GET" path="/v2/foo/manifests/latest" auth="Basic Zm9vOmJhcg=="
2025/09/04 09:48:12 response: id=kPeRusEu status=200 bytes=0 challenges=[]
2025/09/04 09:48:14 request: id=ipKxBCNp method="HEAD" path="/v2/bar/manifests/latest" auth=""
2025/09/04 09:48:14 response: id=ipKxBCNp status=200 bytes=0 challenges=[]
2025/09/04 09:48:14 request: id=uJQVhqRt method="GET" path="/v2/bar/manifests/latest" auth=""
2025/09/04 09:48:14 response: id=uJQVhqRt status=200 bytes=0 challenges=[]
With containerd, per-repo authentication challenges are respected.
Docker version
Client: Docker Engine - Community
Version: 27.5.1
API version: 1.47
Go version: go1.22.11
Git commit: 9f9e405
Built: Wed Jan 22 13:41:48 2025
OS/Arch: linux/amd64
Context: default
Server: Docker Engine - Community
Engine:
Version: 27.5.1
API version: 1.47 (minimum version 1.24)
Go version: go1.22.11
Git commit: 4c9b3b0
Built: Wed Jan 22 13:41:48 2025
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: 1.7.25
GitCommit: bcc810d6b9066471b0b6fa75f557a15a1cbf31bb
runc:
Version: 1.2.4
GitCommit: v1.2.4-0-g6c52b3f
docker-init:
Version: 0.19.0
GitCommit: de40ad0
Docker info
Client: Docker Engine - Community
Version: 27.5.1
Context: default
Debug Mode: false
Plugins:
buildx: Docker Buildx (Docker Inc.)
Version: v0.20.0
Path: /usr/libexec/docker/cli-plugins/docker-buildx
compose: Docker Compose (Docker Inc.)
Version: v2.32.4
Path: /usr/libexec/docker/cli-plugins/docker-compose
Server:
Containers: 0
Running: 0
Paused: 0
Stopped: 0
Images: 0
Server Version: 27.5.1
Storage Driver: overlay2
Backing Filesystem: extfs
Supports d_type: true
Using metacopy: false
Native Overlay Diff: true
userxattr: false
Logging Driver: json-file
Cgroup Driver: systemd
Cgroup Version: 2
Plugins:
Volume: local
Network: bridge host ipvlan macvlan null overlay
Log: awslogs fluentd gcplogs gelf journald json-file local splunk syslog
Swarm: inactive
Runtimes: io.containerd.runc.v2 runc
Default Runtime: runc
Init Binary: docker-init
containerd version: bcc810d6b9066471b0b6fa75f557a15a1cbf31bb
runc version: v1.2.4-0-g6c52b3f
init version: de40ad0
Security Options:
apparmor
seccomp
Profile: builtin
cgroupns
Kernel Version: 6.8.0-52-generic
Operating System: Ubuntu 24.04.1 LTS
OSType: linux
Architecture: x86_64
CPUs: 2
Total Memory: 1.875GiB
Name: docker-ce-ubuntu-2gb-ash-1
ID: 2161eaf8-64b8-4aa5-93ec-fb31e476b76a
Docker Root Dir: /var/lib/docker
Debug Mode: true
File Descriptors: 23
Goroutines: 41
System Time: 2025-09-04T15:16:13.32925468Z
EventsListeners: 0
Experimental: false
Insecure Registries:
127.0.0.0/8
Live Restore Enabled: false
Notes
- This may be a
docker/cliissue more than amoby/mobyissue, but I wasn't sure.
- Dominant language
- Go
- Stars
- 72.1k
- Forks
- 19.2k
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 164
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from moby/moby
-
kind/bug
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
-
kind/bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
-
kind/bug status/0-triage
Difficulty 1/5 Under an hour Newbie friendliness 62/100
-
area/security/seccomp help wanted kind/enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
Similar issues
-
optimization optimization:agents-md-curator
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
githubnext/gh-aw-cao#13143 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
blinklabs-io/bursa#904 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
yanet-platform/ipfw-go#129 ·
-
bug confmap/provider/googlesecretmanagerprovider needs triage
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
open-telemetry/opentelemetry-collector-contrib#51273 · 2 comments ·
-
bug: AI Gateway client filter lists "Unknown" twice when NULL and literal Unknown clients coexist Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 90/100