Skip to content

fix: Reject JWT tokens minted for a different registry - #154

Merged
mattjohnsonpint merged 1 commit into
mainfrom
mjp/jwt-fix
Aug 17, 2026
Merged

fix: Reject JWT tokens minted for a different registry#154
mattjohnsonpint merged 1 commit into
mainfrom
mjp/jwt-fix

Conversation

@mattjohnsonpint

Copy link
Copy Markdown
Contributor

createToken() recorded the target registry in the token's aud claim, but verifyToken() never checked it — only the signature, expiry and capabilities were validated. Every deployment trusting the same JWT_REGISTRY_TOKENS_PUBLIC_KEY therefore accepted tokens minted for any of the others, letting a token holder on one registry replay into another, for example dev into prod.

Nothing contained such a token once it was accepted, because the registry has no per-account or per-repository scoping. Replayed against a second registry, a pull token returned the victim manifest (200) and a push token opened a blob upload session (202).

verifyToken() now compares aud against the host the request arrived on, before any capability is honoured. Host and port must match; scheme and path are ignored, since registries are distinguished by host and ignoring the scheme keeps local http development working with https audiences.

Tokens with no usable aud are now rejected rather than skipped. aud is already required by RegistryAuthProtocolTokenPayload and always set by createToken(), so accepting tokens without it would have left the bypass reachable.

Only the JWT path changes. Basic auth reuses verifyPayload() for its capability checks and has no audience of its own.

BREAKING: issuers that omit aud, or set it to anything but the registry's host, now get 401. Give each deployment its own key pair — those sharing a public key form one trust domain, and this check is its only internal separator.

`createToken()` recorded the target registry in the token's `aud` claim, but
`verifyToken()` never checked it — only the signature, expiry and capabilities
were validated. Every deployment trusting the same
`JWT_REGISTRY_TOKENS_PUBLIC_KEY` therefore accepted tokens minted for any of
the others, letting a token holder on one registry replay into another, for
example dev into prod.

Nothing contained such a token once it was accepted, because the registry has
no per-account or per-repository scoping. Replayed against a second registry, a
`pull` token returned the victim manifest (`200`) and a `push` token opened a
blob upload session (`202`).

`verifyToken()` now compares `aud` against the host the request arrived on,
before any capability is honoured. Host and port must match; scheme and path
are ignored, since registries are distinguished by host and ignoring the scheme
keeps local http development working with https audiences.

Tokens with no usable `aud` are now rejected rather than skipped. `aud` is
already required by `RegistryAuthProtocolTokenPayload` and always set by
`createToken()`, so accepting tokens without it would have left the bypass
reachable.

Only the JWT path changes. Basic auth reuses `verifyPayload()` for its
capability checks and has no audience of its own.

BREAKING: issuers that omit `aud`, or set it to anything but the registry's
host, now get `401`. Give each deployment its own key pair — those sharing a
public key form one trust domain, and this check is its only internal
separator.
@mattjohnsonpint
mattjohnsonpint merged commit a73605d into main Aug 17, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants