fix: Reject JWT tokens minted for a different registry - #154
Merged
Conversation
`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.
nikitassharma
approved these changes
Aug 17, 2026
gabivlj
approved these changes
Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
createToken()recorded the target registry in the token'saudclaim, butverifyToken()never checked it — only the signature, expiry and capabilities were validated. Every deployment trusting the sameJWT_REGISTRY_TOKENS_PUBLIC_KEYtherefore 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
pulltoken returned the victim manifest (200) and apushtoken opened a blob upload session (202).verifyToken()now comparesaudagainst 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
audare now rejected rather than skipped.audis already required byRegistryAuthProtocolTokenPayloadand always set bycreateToken(), 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 get401. Give each deployment its own key pair — those sharing a public key form one trust domain, and this check is its only internal separator.