Update 2024-03-04: Below, I mention that GHCR does not return proper scope in the www-authenticate header, which is not correct. GHCR returns the hint repository:user/image:pull only if no real repository is requested (e.g. for the /v2/ and _catalog endpoints). If I request an existing repository (e.g. toddysm/python) the scope is properly set as repository:toddysm/python:pull in the www-authenticate header and can be used in the subsequent call to request the token.

Additional detail about this is that if you specify valid username and password to the user/image scope, you will receive a valid token. However, if it is anonymous request is denied for this call.

Another issue with GHCR is that it does not reply with the expiration of the token. I can assume a default of 60 seconds but this is not explicitly communicated by the API. This may result in errors when requesting the APIs and I need to implement preemptive logic to request authentication tokens.

In my last post Authenticating with OCI Registries – Docker Hub Implementation, I went through the authentication flow for Docker Hub. In this one, I will go through the same steps for GitHub Container Registry (aka GHCR) and highlight the most important differences with the Docker Hub flow. As a reminder, here are the authentication scenarios that I will go over:

  • Authentication for the root of the registry (i.e https://myregistry.provider.com/)
  • Authentication flow for the /v2/ endpoint
  • Authentication flow for the /v2/_catalog endpoint
  • Authentication flow for any /v2/repository endpoint

GitHub Container Registry (GHCR) Root URL Authentication

GHCR has a single DNS used to access the registry and the authentication endpoint: ghcr.io. The authentication endpoint is accessible at the ghcr.io/token path. Requesting the root URL of the registry GET https://ghcr.io returns the GitHub Packages landing page. The behavior is the same for both HTTP (insecure) and HTTPS requests.

GitHub Container Registry (GHCR) /v2/ Endpoint Authentication

As per specification, the /v2/ endpoint returns an authentication challenge.

### Get the /v2/ endpoint
### REQUEST
GET https://ghcr.io/v2/

### RESPONSE
HTTP/1.1 401 Unauthorized
Content-Type: application/json
docker-distribution-api-version: registry/2.0
www-authenticate: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"
Date: Mon, 05 Feb 2024 04:47:32 GMT
Content-Length: 73
X-GitHub-Request-Id: 1E51:2DB2E6:17EDE:3D83A:65C06864
connection: close

{
  "errors": [
    {
      "code": "UNAUTHORIZED",
      "message": "authentication required"
    }
  ]
}

The problem with this authentication challenge is that I do not get a valid scope for authentication. If I try to authenticate using the scope specified in the challenge, I get the following error:

### Authenticate for the /v2/ using the default challenge information
### REQUEST
GET https://ghcr.io/token?service=ghcr.io&scope=repository:user/image:pull

### RESPONSE
HTTP/1.1 403 Forbidden
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Date: Mon, 05 Feb 2024 04:50:21 GMT
Content-Length: 86
X-GitHub-Request-Id: 53D3:309B07:D434:225D0:65C0690D
connection: close

{
  "errors": [
    {
      "code": "DENIED",
      "message": "requested access to the resource is denied"
    }
  ]
}

This response does not provide any more useful information how to authenticate with the registry’s /v2/ endpoint. Trying to authenticate without scope information, similar to the Docker Hub approach, returns the same 403 Forbidden error as above. I tried also different approaches like using the user only (i.e. scope=repository:toddysm:pull) or wildcard (i.e. scope=repository:*:pull). They both return 400 Bad Request response.

The only way I found to get a valid authentication token for the /v2/ endpoint is to use a valid repository name. For example, requesting a test repository under my username returns the following:

### Authenticate for the /v2/ endpoint
### REQUEST
GET https://ghcr.io/token?service=ghcr.io&scope=repository:toddysm/python:pull

### RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Date: Mon, 05 Feb 2024 04:59:31 GMT
Content-Length: 65
X-GitHub-Request-Id: 29D4:10F50B:10B99A:1D4E0F:65C06B33
connection: close

{
  "token": "djE6dG9kZHl<redacted>"
}

With this token, I can authenticate with the /v2/ endpoint and get a response:

### Get the /v2/ endpoint
### REQUEST
GET https://ghcr.io/v2/
Authorization: Bearer djE6dG9kZHl<redacted>

### RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Date: Mon, 05 Feb 2024 05:01:32 GMT
Content-Length: 0
X-GitHub-Request-Id: CC38:1762F4:9382E:13C1B3:65C06BAC
connection: close

The interesting part here is that the response has an empty body. Note, this is an empty body and not an empty JSON (i.e. {}) as the specification requires.

One interesting behavior I noticed is that requesting the /v2 endpoint (without trailing slash) without a token returns authentication challenge but requesting it with a valid token returns 404 Not Found error.

GitHub Container Registry (GHCR) _catalog Authentication Flow

Unlike Docker Hub, GHCR allows you to list the _catalog for the registry. There are a few specifics about it, though. First, I cannot list the catalog with an anonymous token (i.e. the one that I receive without specifying my credentials). To list the _catalog, I had to authenticate with my own credentials (again, using a valid repository scope for the token). Here the steps:

### Authenticate with username and password for the /v2/toddysm/python
### REQUEST
GET https://ghcr.io/token?service=ghcr.io&scope=repository:toddysm/python:pull,push,delete
Authorization: Basic <your_base64_encoded_credentials>

### RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Date: Mon, 12 Feb 2024 17:23:50 GMT
Content-Length: 69
X-GitHub-Request-Id: A260:2D6237:BC2C:138E3:65CA5426
connection: close

{
  "token": "Z2hwX<redacted>"
}

### Get the /v2/_catalog with individual auth token
### REQUEST
GET https://ghcr.io/v2/_catalog
Authorization: Bearer Z2hwX<redacted>

### RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Link: </v2/_catalog?last=0b2ce%2Fcoreutils&n=0>; rel="next"
Date: Mon, 12 Feb 2024 17:27:14 GMT
Transfer-Encoding: chunked
X-GitHub-Request-Id: CF8A:2D239F:A9FF:1285D:65CA54F1
connection: close

{
  "repositories": [
    "0-5788719150923125/ctx",
    "0-5788719150923125/dht",
    "0-5788719150923125/lab",
    "0-5788719150923125/src",
    "0-5788719150923125/uxo",
    ...
  ]
}

The other interesting thing is that I receive a list of all repositories I have access to with my credentials. My assumption is that this includes every publicly accessible repository in GHCR (including mine). It would be interesting to know whether private repositories I have access to are also included in the _catalog. I have not dug into this scenario – something to do in the future.

GitHub Container Registry (GHCR) Repository Authentication Flow

As with the Docker Hub investigation, I will do two things here: authenticate using anonymous token to pull from public repositories (I will pull from my test public repository toddysm/python) and to pull, push, and delete from private repositories (I can use the same repo to test the push and delete capability).

Authentication Flow for Pulling from Public Repositories on GHCR

For this one, I will just get an anonymous token and list the tags and/or pull the manifest for a single tag from my publicly accessible repository toddysm/python. This works as expected as shown by the following output:

### Authenticate with anonymous token
### REQUEST
GET https://ghcr.io/token?service=ghcr.io&scope=repository:toddysm/python:pull

### RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Date: Mon, 12 Feb 2024 18:31:05 GMT
Content-Length: 65
X-GitHub-Request-Id: D35A:2D7E60:15F6D:1FCAE:65CA63E9
connection: close

{
  "token": "djE6dG9kZH<redacted>"
}

### List the tags for the toddysm/python repository
### REQUEST
GET https://ghcr.io/v2/toddysm/python/tags/list
Authorization: Bearer djE6dG9kZH<redacted>

### RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Date: Mon, 12 Feb 2024 18:33:08 GMT
Content-Length: 42
X-GitHub-Request-Id: 36CE:2DA544:CA96:168D1:65CA6463
connection: close

{
  "name": "toddysm/python",
  "tags": [
    "3.10"
  ]
}

### Get the manifest for toddysm/python:3.10 image
### REQUEST
GET https://ghcr.io/v2/toddysm/python/manifests/3.10
Authorization: Bearer djE6dG9kZH<redacted>

### RESPONSE
HTTP/1.1 200 OK
Content-Length: 1370
Content-Type: application/vnd.docker.distribution.manifest.v2+json
docker-content-digest: sha256:1d12eaa626fc3387d0e952eb6e7d3ab5f518f02bb75d14a3089ff4bef19970a1
docker-distribution-api-version: registry/2.0
ETag: "sha256:1d12eaa626fc3387d0e952eb6e7d3ab5f518f02bb75d14a3089ff4bef19970a1"
Date: Mon, 12 Feb 2024 18:33:19 GMT
X-GitHub-Request-Id: 2409:2D16E4:D5F2:1744F:65CA646E
connection: close

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 6951,
    "digest": "sha256:083ea201829c5a731ab9a1f43b5b8c4dae317d00a80b6926cdc98c56b824a957"
  },
  "layers": [
    ...
  ]
}

Trying to DELETE the manifest with an anonymous token results in 405 Method Not Allowed error:

### Delete the toddysm/python:3.10 manifest with anonymous token
### REQUEST
DELETE https://ghcr.io/v2/toddysm/python/manifests/3.10
Authorization: Bearer djE6dG9kZH<redacted>

### RESPONSE
HTTP/1.1 405 Method Not Allowed
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Date: Mon, 12 Feb 2024 18:37:32 GMT
Content-Length: 78
X-GitHub-Request-Id: 5D17:2DA544:CD0D:16D6C:65CA656C
connection: close

{
  "errors": [
    {
      "code": "UNSUPPORTED",
      "message": "The operation is unsupported."
    }
  ]
}

That is a little bit unexpected for me because I would expect to see 403 Forbidden error. My assumption is that POST and PUT have the same behavior – I have not tested those. HEAD requests work as expected and I get 200 OK as response.

### HEAD request to the toddysm/python:3.10 manifest with anonymous token
### REQUEST
HEAD https://ghcr.io/v2/toddysm/python/manifests/3.10
Authorization: Bearer djE6dG9kZHl<redacted>

### RESPONSE
HTTP/1.1 200 OK
Content-Length: 1370
Content-Type: application/vnd.docker.distribution.manifest.v2+json
docker-content-digest: sha256:1d12eaa626fc3387d0e952eb6e7d3ab5f518f02bb75d14a3089ff4bef19970a1
docker-distribution-api-version: registry/2.0
ETag: "sha256:1d12eaa626fc3387d0e952eb6e7d3ab5f518f02bb75d14a3089ff4bef19970a1"
Date: Mon, 12 Feb 2024 18:41:03 GMT
X-GitHub-Request-Id: 48C5:386761:FCA3:19EB8:65CA663F
connection: close

The above behavior is somewhat expected (except the 405 response) – the reason being that I have requested a token with only pull permissions. Trying to request an anonymous token with broader range of permissions results in 403 Forbidden error:

### Authenticate for pull,push,delete for the toddysm/python repository
### REQUEST
GET https://ghcr.io/token?service=ghcr.io&scope=repository:toddysm/python:pull,push,delete

### RESPONSE
HTTP/1.1 403 Forbidden
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Date: Mon, 12 Feb 2024 18:50:29 GMT
Content-Length: 86
X-GitHub-Request-Id: 0D82:2D38BB:E6F6:18D9E:65CA6875
connection: close

{
  "errors": [
    {
      "code": "DENIED",
      "message": "requested access to the resource is denied"
    }
  ]
}

I like this response because it tells me that I cannot request broader permissions anonymously.

Authentication Flow for Pull, Push, and Delete from a Private Repositories on GHCR

To push and delete images from a repository, I need to authenticate with valid credentials. Same approach I have done to list the _catalog. Trying to DELETE a manifest with this token, I get the 405 Method Not Allowed error:

### Authenticate with username and password for the toddysm/python repository
GET https://ghcr.io/token?service=ghcr.io&scope=repository:toddysm/python:pull,push,delete
Authorization: Basic <your_base64_encoded_credentials>

### RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Date: Mon, 12 Feb 2024 19:01:43 GMT
Content-Length: 69
X-GitHub-Request-Id: 7AA5:2D38BB:EF62:19B79:65CA6B17
connection: close

{
  "token": "Z2hwX<redacted>"
}

### Delete a manifest with the auth token
### REQUEST
DELETE https://ghcr.io/v2/toddysm/python/manifests/3.10
Authorization: Bearer Z2hwX<redacted>

### RESPONSE
HTTP/1.1 405 Method Not Allowed
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Date: Mon, 12 Feb 2024 18:57:53 GMT
Content-Length: 78
X-GitHub-Request-Id: 4CAD:2D681D:F112:19B4E:65CA6A31
connection: close

{
  "errors": [
    {
      "code": "UNSUPPORTED",
      "message": "The operation is unsupported."
    }
  ]
}

Now, this is a bit more puzzling. It seems the delete operation on the manifest is not allowed. Of course, this begs the obvious question: “How do you delete images from the registry?” To better understand the behavior, I tried to delete a blob as well as put a new manifest into the repository.

Deleting a blob yields the same result as deleting a manifest – 405 Method Not Allowed:

### Delete a blob with the auth token
### REQUEST
DELETE https://ghcr.io/v2/toddysm/python/blobs/1bc163a14ea6a886d1d0f9a9be878b1ffd08a9311e15861137ccd85bb87190f9
Authorization: Bearer Z2hwX<redacted>

### RESPONSE
HTTP/1.1 405 Method Not Allowed
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Date: Mon, 12 Feb 2024 19:50:24 GMT
Content-Length: 78
X-GitHub-Request-Id: 26D4:2D332B:1925B:255FA:65CA7680
connection: close

{
  "errors": [
    {
      "code": "UNSUPPORTED",
      "message": "The operation is unsupported."
    }
  ]
}

Putting a manifest into the registry with the same token succeeds:

### Put a manifest with the auth token
### REQUEST
PUT https://ghcr.io/v2/toddysm/python/manifests/3.11
Authorization: Bearer Z2hwX<redacted>
Content-Type: application/vnd.docker.distribution.manifest.v2+json

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 6951,
    "digest": "sha256:083ea201829c5a731ab9a1f43b5b8c4dae317d00a80b6926cdc98c56b824a957"
  },
  "layers": [
   ...
  ]
}

### RESPONSE
HTTP/1.1 201 Created
Content-Type: application/json
docker-content-digest: sha256:e7aa768fe7588f909d3463a6d774d182ec967db40eb59c34868ed7d6f5977484
docker-distribution-api-version: registry/2.0
Location: /v2/toddysm/python/manifests/sha256:e7aa768fe7588f909d3463a6d774d182ec967db40eb59c34868ed7d6f5977484
Date: Mon, 12 Feb 2024 19:56:50 GMT
Content-Length: 0
X-GitHub-Request-Id: D3C0:2D6593:10052:1C70C:65CA7802
connection: close

There is a known inconsistency in how registries are implementing the delete capability and my assumption is that GHCR went with the above approach. From authentication and authorization point of view, the above behavior seems functional.

Summary

A couple of things I learned from this experience:

  • The authentication experience for GHCR is not so explicit as the Docker Hub one. The scope in the www-authentication header cannot be used for crafting the authentication request.
  • The use of a consistent DNS for both, the registry and the authentication, seems convenience. Also the realm in the www-authentication header is reliable.
  • I can rely that the registry will reply with the proper response when I request permissions. This can save some calls to the registry compared to the Docker Hub’s approach.
  • As with Docker Hub, GHCR’s responses may be confusing. For example, I am not sure how a client should implement the delete capability for GHCR. Also, requesting the right token for the _catalog is convoluted.

Two things that I noted for further investigation. One, is the delete behavior for each of the registries I investigate. I will separate that from the authentication investigations to make sure the scope doesn’t increase significantly. Second, is to investigate the capabilities for push other types of content and not only container images.

Update 2024-02-03: I just learned this week that index.docker.io is another DNS used for access to the Docker Hub registry. The behavior of index.docker.io is the same as registry-1.docker.io, so no changes to the commands are added. The list of DNS names is updated in the post below.

As part of my role in the Open Containers Initiative’s (OCI) Authentication Working Group, I started looking at the current state of authentication across registries. It is in my plan to document the current implementation in a list of registries including Docker Hub, GitHub Container Registry, ACR, ECR, GCR, NVIDIA, JFrog Artifactory, Harbor, and Zot. The goal is to identify differences and work on a proposal to unify those in a OCI registry authentication. For the purpose of this investigation, I will use only the APIs as specified in the OCI Distribution specification and the token authentication specified in Distribution Registry Authentication. For this post, I will concentrate on how Docker Hub currently (i.e. Jan 2024) handle authentication. This can be used as a baseline for comparison with other registries.

Registry Authentication Scenarios

For each of the registries above, I will look at the following authentication scenarios.

  • Authentication for the root of the registry (i.e https://myregistry.provider.com/)
  • Authentication flow for the /v2/ endpoint
  • Authentication flow for the /v2/_catalog endpoint
  • Authentication flow for any /v2/<repository> endpoint

Note that the distribution registry authentication flows also authorize the user for certain action on the repositories. I will look at how this is done and what permissions the users receive after each call.

For my tests, I will use the Visual Studio Code REST Client and will post the actual API calls below. You can easily reproduce those calls with curl.

Docker Hub Root URL Authentication

Before I look at the authentication for the root URL, there are a few different DNS entries you need to keep in mind when working with Docker Hub:

  • hub.docker.com is the DNS used to access the Docker Hub’s web interface
  • registry-1.docker.io is the DNS used to access the registry APIs
  • index.docker.io is used to access the registry API (same as registry-1.docker.io)
  • registry.docker.io is a placeholder DNS used to issue the authentication token for
  • auth.docker.io is the authentication endpoint

This set up can be a bit confusing for novice users of the APIs and it also poses some challenges with the flows that I will explain below. From user experience point of view, the assumption may be to use the web site DNS for the APIs, which will be wrong. For Docker Hub, the main DNS that API calls should be made (based on my investigation) is registry-1.docker.io.

Hitting the root of registry-1.docker.io as follows does not return authentication challenge but 404 error.

### Get the registry root
### REQUEST 
GET https://registry-1.docker.io/

### RESPONSE
HTTP/1.1 404 Not Found
content-type: text/plain; charset=utf-8
docker-distribution-api-version: registry/2.0
x-content-type-options: nosniff
date: Tue, 30 Jan 2024 00:48:25 GMT
content-length: 19
strict-transport-security: max-age=31536000
connection: close

404 page not found

Docker Hub automatically switches to HTTPS if the root is requested via HTTP. I am mentioning this because not every registry implements this redirection (as I recall seeing in the past). The behavior of the root is not specified in any specification and it is left to the vendors to implement as they find convenient. There shouldn’t be any expectation for consistency across registries.

Docker Hub /v2/ Endpoint Authentication

According to the specifications /v2/ endpoint should return authentication challenge. Note that the trailing slash is required according to specification. Here the result of a simple GET request on /v2 (without trailing slash):

### Get the /v2 endpoint
### REQUEST 
GET https://registry-1.docker.io/v2

### RESPONSE
HTTP/1.1 401 Unauthorized
content-type: application/json
docker-distribution-api-version: registry/2.0
www-authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io"
date: Tue, 30 Jan 2024 00:58:22 GMT
content-length: 87
strict-transport-security: max-age=31536000
connection: close

{
  "errors": [
    {
      "code": "UNAUTHORIZED",
      "message": "authentication required",
      "detail": null
    }
  ]
}

HEAD request returns the same headers without the body. POST, PUT and DELETE requests return 301 redirect to /v2/ (with a slash), and I would expect that GET and HEAD do the same. It is a separate question if /v2/ (with a slash) is considered a resource or a path. I would normally consider this a path and not a resource. Nevertheless, GET and HEAD requests behave differently from POST, PUT and DELETE – I would expect all of them to have a permanent redirect from /v2 to /v2/.

When requesting /v2/ all methods return the same response:

### RESPONSE
HTTP/1.1 401 Unauthorized
content-type: application/json
docker-distribution-api-version: registry/2.0
www-authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io"
date: Tue, 30 Jan 2024 03:15:13 GMT
content-length: 87
strict-transport-security: max-age=31536000
connection: close

{
  "errors": [
    {
      "code": "UNAUTHORIZED",
      "message": "authentication required",
      "detail": null
    }
  ]
}

Note that the OCI spec does not specify any behavior for HEAD, POST, PUT, and DELETE for /v2/ endpoint. This means the implementation is left to the vendor. I would expect to see 405 (Method not supported) for anything else than GET. Current behavior on Docker Hub leaves me with the impression that I may be able to modify or delete the /v2/ (resource). OPTIONS request also works on the /v2/ endpoint with the same result as above.

Authentication Flow for /v2/ Endpoint on Docker Hub

Now that I know what is the behavior of the /v2/ endpoint, let’s go over the authentication flow. As you can see from the www-authenticate header above, I need to make a request to realm="https://auth.docker.io/token" with parameter service="registry.docker.io". Here is where things become confusing with the DNS names that Docker Hub returns. About that a bit later though. Here is the call to complete the flow:

### Authenticate for the /v2/ endpoint
### REQUEST
GET https://auth.docker.io/token?service=registry.docker.io

### RESPONSE
HTTP/1.1 200 OK
content-type: application/json
x-trace-id: 30f17bf77f44bfe9593e7ad8ccff8d1a
date: Tue, 30 Jan 2024 03:35:39 GMT
transfer-encoding: chunked
strict-transport-security: max-age=31536000
connection: close

{
  "token": "eyJhbGciOiJSUzI1N<redacted>",
  "access_token": "eyJhbGciOiJSUzI1N<redacted>",
  "expires_in": 300,
  "issued_at": "2024-01-30T03:35:39.896023447Z"
}

Using the retrieved access-token (which is the same as the token in the response body), I can call the /v2/ endpoint and get the expected response:

### Get the /v2/ endpoint with auth token
### REQUEST
GET https://registry-1.docker.io/v2/
Authorization: Bearer eyJhbGciOiJSUzI1N<redacted>

### RESPONSE
HTTP/1.1 200 OK
content-length: 2
content-type: application/json
docker-distribution-api-version: registry/2.0
date: Tue, 30 Jan 2024 03:39:05 GMT
strict-transport-security: max-age=31536000
connection: close

{}

Docker Hub Registry DNS

Now, what is the story with the Docker Hub registry DNS? The confusing part is that I interact with registry-1.docker.io but the authentication challenge service and the token’s audience are issued for registry.docker.io. I would expect the challenge service to be the same as the DNS I interact with. Here is what happens if you try to access /v2/ endpoint on registry.docker.io:

### Get the /v2/ endpoint with auth token on registry.docker.io
### REQUEST
GET https://registry.docker.io/v2/
Authorization: Bearer eyJhbGciOiJSUzI1N<redacted>

### RESPONSE
HTTP/1.0 503 Service Unavailable
cache-control: no-cache
content-type: text/html

<html><body><h1>503 Service Unavailable</h1>
No server is available to handle this request.
</body></html>

This means that in my code I will need to have a special handling for Docker Hub to ignore the service from the authentication challenge (and aud , i.e. “Audience” in the token) and always use registry-1.docker.io.

/v2/ Endpoint Token Use

If you are wondering if you can use the token you received for the /v2/ endpoint for other purposes, the answer is “No”. Here is what happens if I try to use that token for requesting the  /v2/_catalog:

### Get the /v2/_catalog with /v2/ issues auth token
### REQUEST
GET https://registry-1.docker.io/v2/_catalog
Authorization: Bearer eyJhbGciOiJSUzI1N<redacted>

### RESPONSE
HTTP/1.1 401 Unauthorized
content-type: application/json
docker-distribution-api-version: registry/2.0
www-authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="registry:catalog:*",error="insufficient_scope"
date: Tue, 30 Jan 2024 03:53:37 GMT
content-length: 145
strict-transport-security: max-age=31536000
connection: close

{
  "errors": [
    {
      "code": "UNAUTHORIZED",
      "message": "authentication required",
      "detail": [
        {
          "Type": "registry",
          "Class": "",
          "Name": "catalog",
          "Action": "*"
        }
      ]
    }
  ]
}

Or eventually pull the manifest for any image:

### Get the /v2/library/python/manifests/3.10 with /v2/ issues auth token
### REQUEST
GET https://registry-1.docker.io/v2/library/python/manifests/3.10
Authorization: Bearer eyJhbGciOiJSUzI1N<redacted>

### RESPONSE
HTTP/1.1 401 Unauthorized
content-type: application/json
docker-distribution-api-version: registry/2.0
www-authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/python:pull",error="insufficient_scope"
date: Tue, 30 Jan 2024 03:55:44 GMT
content-length: 157
strict-transport-security: max-age=31536000
docker-ratelimit-source: 50.46.250.96
connection: close

{
  "errors": [
    {
      "code": "UNAUTHORIZED",
      "message": "authentication required",
      "detail": [
        {
          "Type": "repository",
          "Class": "",
          "Name": "library/python",
          "Action": "pull"
        }
      ]
    }
  ]
}

In both cases I get 401 (Unauthorized) error with “insufficient_scope”. I will need to request a new token with the respective scope for each one of the calls above.

Docker Hub _catalog Authentication Flow

Retrieving the catalog of the registry maybe a common operation that you may want to do. Interestingly the /v2/_catalog endpoint is not specified by OCI (and the page doesn’t exist anymore on Docker documentation). This means that the behavior of the /v2/_catalog endpoint is defined by the respective vendor. I should not expect any consistency across vendors. Based on the response I got above for the /v2/_catalog endpoint:

www-authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="registry:catalog:*",error="insufficient_scope"

I should make a request to realm="https://auth.docker.io/token" with parameters service=registry.docker.io&scope=registry:catalog:*. Trying to retrieve a token for this request returns the following:

### Authenticate for the /v2/_catalog endpoint
### REQUEST
GET https://auth.docker.io/token?service=registry.docker.io&scope=registry:catalog:*

### RESPONSE
HTTP/1.1 400 Bad Request
content-type: application/json
x-trace-id: ee3b9e9dfb535dee0f4980ffc007e62c
date: Tue, 30 Jan 2024 04:14:25 GMT
content-length: 36
strict-transport-security: max-age=31536000
connection: close

{
  "details": "unknown resource type"
}

Although the challenge tells you that you can (theoretically) request the catalog, the authentication call responds with “unknown resource type”. It is obvious that Docker doesn’t want to return the whole catalog of thousands of images but I would expect to receive a 404 error and save a request in this case.

Docker Hub Repository Authentication Flow

For this scenario, I will do two different tests. The first one will be to a common public repository, while the second one to my repository on Docker Hub.

Authentication Flow for Pulling from Public Repositories on Docker Hub

As you saw from the above request to pull the Python image manifest, I need to make a request to realm="https://auth.docker.io/token" with parameters service=registry.docker.io&scope=repository:library/python:pull. Here is what I can do with the token I retrieve from this authentication request:

### Authenticate for the /v2/library/python/manifests/3.10 endpoint
### REQUEST
GET https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/python:pull

### RESPONSE
HTTP/1.1 200 OK
content-type: application/json
x-trace-id: f4abf5cfc5be3e7d4d1ddb4e1954e55c
date: Tue, 30 Jan 2024 04:24:21 GMT
transfer-encoding: chunked
strict-transport-security: max-age=31536000
connection: close

{
  "token": "eyJhbGciOiJSUzI1N<redacted>",
  "access_token": "eyJhbGciOiJSUzI1N<redacted>",
  "expires_in": 300,
  "issued_at": "2024-01-30T04:24:21.670237722Z"
}

First, I can pull the manifest for the image. Of course, this is expected, because this is the original endpoint that I requested:

### Pull the manifest for the python:3.10 image
### REQUEST
GET https://registry-1.docker.io/v2/library/python/manifests/3.10
Authorization: Bearer eyJhbGciOiJSUzI1N<redacted>

### RESPONSE
HTTP/1.1 200 OK
content-length: 13886
content-type: application/vnd.docker.distribution.manifest.v1+prettyjws
docker-content-digest: sha256:10f8e59b0b74e09797ea8bcebb7fb20ce8afb1ac303a1c4fcd334f7599befa57
docker-distribution-api-version: registry/2.0
etag: "sha256:10f8e59b0b74e09797ea8bcebb7fb20ce8afb1ac303a1c4fcd334f7599befa57"
date: Tue, 30 Jan 2024 04:27:05 GMT
strict-transport-security: max-age=31536000
ratelimit-limit: 100;w=21600
ratelimit-remaining: 100;w=21600
docker-ratelimit-source: 50.46.250.96
connection: close

{
  "schemaVersion": 1,
  "name": "library/python",
  "tag": "3.10",
  "architecture": "amd64",
  ...
}

I can also list the tags:

### List the tags for the python repository
### REQUEST
GET https://registry-1.docker.io/v2/library/python/tags/list
Authorization: Bearer eyJhbGciOiJSUzI1N<redacted>

### RESPONSE
HTTP/1.1 200 OK
content-type: application/json
docker-distribution-api-version: registry/2.0
date: Tue, 30 Jan 2024 04:29:52 GMT
transfer-encoding: chunked
strict-transport-security: max-age=31536000
connection: close

{
  "name": "library/python",
  "tags": [
    "2",
    "2-alpine",
    "2-alpine3.10",
    "2-alpine3.11",
    "2-alpine3.4",
    ...
  ]
}

And eventually pull a blob for the image. It seems all pull operations are allowed with the token. Being curious, I decided to see what would happen if I request pull and push permissions.

### Authenticate for pull and push for the /v2/library/python/manifests/3.10 endpoint
### REQUEST
GET https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/python:pull,push

### RESPONSE
HTTP/1.1 200 OK
content-type: application/json
x-trace-id: 271b6fa7fb60fd7fcb546d9fb32a3d35
date: Tue, 30 Jan 2024 04:55:20 GMT
transfer-encoding: chunked
strict-transport-security: max-age=31536000
connection: close

{
  "token": "eyJhbGciOiJSUzI1N<redacted>",
  "access_token": "eyJhbGciOiJSUzI1NiI<redacted>",
  "expires_in": 300,
  "issued_at": "2024-01-30T04:55:20.734558819Z"
}

The requests succeeds but if I decode the token, I see that the allowed actions are only for pull:

{
      "actions": [
        "pull"
      ],
      "name": "library/python",
      "parameters": {
        "pull_limit": "100",
        "pull_limit_interval": "21600"
      },
      "type": "repository"
    }
  ],
  "aud": "registry.docker.io",
  "exp": 1706590717,
  "iat": 1706590417,
  "iss": "auth.docker.io",
  "jti": "dckr_jti_Q6sjQ6BF5_1o8LJJFE3qCkFsZlo=",
  "nbf": 1706590117,
  "sub": ""
}

Trying to POST, PUT, and DELETE results in 401 (Unauthorized) error. Of course, HEAD requests are possible with this token.

Authentication Flow for Pull, Push, and Delete from a Private Repositories on Docker Hub

All of the requests above were made without specifying user credentials. To test the full permissions, I will use my own repository on Docker Hub. I have also push and delete permissions on this repository. First requesting the token is done with basic authentication:

### Authenticate with username and password for the /v2/toddysm/python
### REQUEST
GET https://auth.docker.io/token?service=registry.docker.io&scope=repository:toddysm/python:pull,push,delete
Authorization: Basic <base64_encoded_username_and_password>

### RESPONSE
HTTP/1.1 200 OK
content-type: application/json
x-trace-id: 409339cb64a91bdfcbaa72da4ce35219
date: Tue, 30 Jan 2024 05:16:37 GMT
transfer-encoding: chunked
strict-transport-security: max-age=31536000
connection: close

{
  "token": "eyJhbGciOiJSUzI1N<redacted>",
  "access_token": "eyJhbGciOiJSUzI1NiI<redacted>",
  "expires_in": 300,
  "issued_at": "2024-01-30T05:16:37.110643594Z"
}

And with the token, you can push data or delete data:

### Delete a manifest with the auth token
### REQUEST
DELETE https://registry-1.docker.io/v2/toddysm/python/manifests/3.10
Authorization: Bearer eyJhbGciOiJSUzI1NiI<redacted>

### RESPONSE
HTTP/1.1 202 Accepted
docker-distribution-api-version: registry/2.0
date: Tue, 30 Jan 2024 05:18:33 GMT
content-length: 0
strict-transport-security: max-age=31536000
docker-ratelimit-source: 50.46.250.96
connection: close

Summary

Here is a quick summary of the main points above:

  • Even for anonymous access, Docker Hub requires you to request a token to access its APIs.
  • Docker Hub registry uses different DNS names for the actual registry endpoint and the token audience. This may have some impact on how you implement the API calls and the authentication header parsing.
  • Docker Hub registry doesn’t support _catalog despite the fact that it responds with proper authentication challenge.
  • When requesting a token for Docker Hub registry, do not rely that you have received the full permissions you requested. You may need to parse the token to discover the access you are given.
  • Some of the request method responses may be confusing and not consistent with other registries.

This completes my investigation on the Docker Hub registry authentication. All commands are also available as a Gist.