Make an OCI index private and issue tokens
By default an OCI index reads anonymously and authenticates only writes. This guide gates reads too, issues tokens scoped to some repositories, and closes a whole server at once. Each section stands alone. The model is the neutral one under authentication and access control; this page is the OCI recipe.
Turn on the realm
Set a signing key under [auth]. Without it peryx cannot mint tokens, so docker login does not validate and reads
stay open.
# peryx.toml
[auth]
signing_key_file = "/run/secrets/peryx-signing-key"
Keep the key in a file, not inline: a mounted Docker or Kubernetes secret, a systemd credential, or a Vault-rendered
file all fit signing_key_file. Check that the mounted file is not empty before starting peryx; an empty or
whitespace-only key stops startup. The key signs every token; rotating it invalidates all outstanding tokens.
Gate an index's reads
Set anonymous_read = false on the index and give a token a read grant. Now a pull needs a token that covers the
repository.
[[index]]
name = "team"
route = "team"
ecosystem = "oci"
hosted = true
anonymous_read = false
[[index.access_token]]
name = "ci"
secret_file = "/run/secrets/ci-token"
projects = ["team/*"]
actions = ["read", "write"]
A client pulls after docker login localhost:4433 --username ci --password <ci-token>. A pull of a repository the token
does not cover, or an anonymous pull, gets 401 insufficient_scope.
The gate covers every web UI read; server rendering uses the incoming credential. After hydration, same-origin /+ui
and /+search requests apply the same ACL. Put the UI behind an authenticating proxy or send an Authorization header
to browse a private index. Search omits inaccessible repositories before calculating totals and pages.
Scope a token to some repositories
projects is a list of globs. * matches any run of characters, / included, so team/* covers every repository
under team however deeply nested, and a bare * covers the whole index. Grant only the verbs the credential needs
under actions (read, write, delete).
[[index.access_token]]
name = "reader"
secret_file = "/run/secrets/reader-token"
projects = ["team/public/*"]
actions = ["read"]
[[index.access_token]]
name = "releaser"
secret_file = "/run/secrets/releaser-token"
projects = ["team/*"]
actions = ["read", "write", "delete"]
An index may carry as many [[index.access_token]] tables as you need. The legacy upload_token shorthand still works
and stands for one credential that writes and deletes everywhere on the index.
Give a token an expiry
Add expires_at, an RFC 3339 timestamp. After it passes, the token stops authenticating and a JWT already minted from
it stops verifying at its own expiry.
[[index.access_token]]
name = "ci-2027"
secret_file = "/run/secrets/ci-2027-token"
projects = ["team/*"]
actions = ["read", "write"]
expires_at = "2027-01-01T00:00:00Z"Allow registry catalog listing
GET /v2/_catalog spans the configured OCI indexes, so it uses registry:catalog:* instead of a repository pull scope.
Give the same named credential an explicit projects = ["*"] read grant on each private OCI index in the catalog. A
team/* grant authorizes matching pulls but cannot list the catalog.
Test the scope with these requests.
token=$(curl -sS -u ci:<ci-token> \
'http://127.0.0.1:4433/v2/token?service=peryx&scope=registry%3Acatalog%3A%2A' | jq -r .token)
curl -sS --oauth2-bearer "$token" http://127.0.0.1:4433/v2/_catalog
peryx names registry:catalog:* in the 401 challenge for a missing credential. It returns 401 insufficient_scope
for a valid token without that exact grant.
Close a whole server
To make every index private with one knob, set the server-wide default instead of touching each index:
[auth]
signing_key_file = "/run/secrets/peryx-signing-key"
default_anonymous_read = false
Each index's anonymous_read takes this default when it sets none, so one line closes reads everywhere; an index that
must stay public overrides it with anonymous_read = true.
Control the token lifetime
token_ttl_secs under [auth] sets how long a minted token lives (default 300 seconds). A shorter lifetime makes a
revoked ACL take hold sooner; a longer one cuts token-endpoint traffic for a busy CI fleet.
[auth]
signing_key_file = "/run/secrets/peryx-signing-key"
token_ttl_secs = 900See also
- Log in and push with a scoped token: the same setup as a walkthrough.
- Control access to an index: the neutral cookbook, PyPI and OCI alike.
- Token authentication: the endpoints and error codes.