Token authentication
peryx implements distribution token authentication so
docker login validates credentials and tokens carry repository scopes. See
authentication and access control for principals and grants, and
Bearer token realm for the protocol design.
Enabling the realm
The realm needs a signing key. Set signing_key (or signing_key_file) under [auth]; without it GET /v2/ never
challenges, GET /v2/token answers 405, and resource routes accept only Basic auth.
# peryx.toml
[auth]
signing_key_file = "/run/secrets/peryx-signing-key"
token_ttl_secs = 300 # how long a minted token lives; default 300
default_anonymous_read = true # per-index anonymous_read default; default true
The key signs an HS256 JWT whose aud claim is peryx. Keep it secret and stable: rotating it invalidates every token
minted under the old key, and sharing it across replicas lets any replica verify a token the primary minted. Audience
validation prevents another service that shares the key from presenting its tokens to this registry. Peryx requires at
least 32 bytes after resolving signing_key or signing_key_file; startup and check-config reject shorter values.
See the shared signing-key guidance before generating or rotating the
key. token_ttl_secs accepts 60 through 86400 seconds when an OCI index enables the realm and defaults to 300.
Version check
GET /v2/ (with or without the trailing slash) answers one of two ways.
200withDocker-Distribution-API-Version: registry/2.0when no OCI index restricts access, or when the request carries a credential the realm accepts (a bearer it signed, or a Basic password one of its indexes issued). This is the frictionless default and thedocker loginsuccess signal.401withWWW-Authenticate: Bearer realm="<base>/v2/token",service="peryx"when an OCI index restricts access. An index restricts when itsanonymous_readisfalseor it carries any named credential.
<base> is the origin peryx is reached at. Peryx accepts X-Forwarded-Host and X-Forwarded-Proto only when the
socket peer belongs to [rate_limit].trusted_proxies; other requests use Host and HTTP. service is always peryx.
Token endpoint
GET /v2/token mints a token. Query parameters:
| Parameter | Meaning |
|---|---|
service | Required challenge service name, peryx |
scope | Repository or registry-catalog access request; repeat the parameter or separate scopes with spaces |
account | Login username recorded for audit but not used for authorization |
Authentication:
- A missing, different, or repeated
servicegets403; peryx never mints a token for another audience. - No
Authorizationheader: the request is anonymous. Basiccredentials: peryx checks the password against every OCI index's tokens. A password that authenticates nowhere gets401, sodocker loginrejects it. A password that authenticates names its subject. A scope on another index is granted only when the same header authenticates as that subject there, so equal token names with different secrets remain isolated.
The response is always 200 on a recognized (or absent) credential, carrying a JWT:
{
"token": "<jwt>",
"access_token": "<jwt>",
"expires_in": 300
}
The token's granted access is the intersection of each requested scope with what the principal may do on the index the
<name> resolves to. An empty intersection is a valid token with no access, not an error: an anonymous request for a
public repository still gets a pull token, and one for a private repository gets a token that carries nothing.
Scope grammar
A repository scope is repository:<name>:<actions>. Include the index route prefix in the full /v2/ repository name,
such as team/app or dockerhub/library/alpine. <actions> is a comma-separated list; peryx maps each verb to a
neutral action:
| Scope verb | Neutral action | Granted for |
|---|---|---|
pull | read | GET or HEAD on a resource |
push | write | PUT, POST, or PATCH |
delete | delete | DELETE |
* | read, write, and delete | Any resource method |
An unknown verb requests nothing; peryx drops a repository scope with an empty name or no configured index.
Distribution assigns registry:catalog:* to the repository catalog. peryx grants it when the requester may read each
OCI index. A public index needs no credential; a private index requires an explicit projects = ["*"] read grant. The
same subject must authenticate on each private index. A team/* grant cannot list the catalog. Unknown resource types
and registry names request nothing; catalog actions other than * behave the same way.
Resource routes
Every /v2/<name>/… route authorizes the request against the index the name resolves to before its handler runs. It
accepts a Bearer JWT the realm signed, a Basic token (so an existing docker login -u _ -p <token> push keeps working),
or no credential (an anonymous read, when the index allows it). The HTTP method picks the action: GET/HEAD read,
PUT/POST/PATCH write, DELETE delete.
A refusal answers 401 with a scoped challenge:
WWW-Authenticate: Bearer realm="<base>/v2/token",service="peryx",scope="repository:<name>:pull,push",error="insufficient_scope"
The error follows RFC 6750:
error | Meaning | Client action |
|---|---|---|
| (none) | A protected route received no credential | Request a token, then retry |
invalid_token | The Bearer token failed signature, expiry, or audience validation | Request a fresh token, then retry |
insufficient_scope | The credential grants no access for this action | Request a broader grant before retrying |
The scope names what the request needed, so a client can request the right token and retry. When no signing key is
configured, resource routes fall back to the Basic challenge (WWW-Authenticate: Basic realm="peryx") instead.
GET /v2/_catalog uses the same refusal shape with scope="registry:catalog:*". peryx returns insufficient_scope for
a repository token and checks the catalog grant before returning private repository names.
Web and search routes
Web and search reads accept the same Authorization header.
| Route | ACL resource | Refusal |
|---|---|---|
/+ui/projects?index=<route> | Every returned repository | No read grant yields 401 or 403 before enumeration |
/+ui/project?index=<route>&project=<repository> | Named repository | Missing credentials yield 401; insufficient grants yield 403 |
/+ui/manifest, /+ui/members, /+ui/member | Their project=<repository> | Missing credentials yield 401; insufficient grants yield 403 |
/+search, /<route>/+search | Full <route>/<repository> name for each result | Query omits inaccessible results |
Server-rendered /browse and /search pages enforce these rules before their data builders run. peryx matches a
verified Bearer token against its full repository scope and resolves Basic credentials against the selected index ACL.
Search inserts the resource globs into its query before counting and pagination. A 401 from /+ui includes
WWW-Authenticate: Basic realm="peryx"; these neutral endpoints accept Basic credentials without an OCI token exchange.
See also
- Authentication and access control: the neutral model these routes enforce.
- Client auth versus upstream credentials: why a cached index never forwards a client's token to its upstream.
- HTTP endpoints: the full
/v2/route table.