Skip to content

Trusted publishing

Every run of a CI job carries a proof of identity: the OIDC id_token its platform mints for it. Haven exchanges that proof for a publish token that lives 30 minutes, so the pipeline keeps no stored secret. This is Haven’s equivalent of crates.io trusted publishing, and the recommended way to publish from CI. Stored tokens cover the cases the exchange cannot.

The exchange grants nothing by itself. Haven verifies the id_token and derives an identity string from it, issuer#subject. For a GitHub Actions job publishing from acme/tools:

https://token.actions.githubusercontent.com#repo:acme@123456/tools@7890123:ref:refs/heads/main

The @123456 and @7890123 are GitHub’s numeric account and repository IDs, embedded so the identity survives renames. GitHub uses this immutable format by default for repositories created after 2026-07-15; older repositories emit the plain repo:acme/tools:ref:refs/heads/main unless opted in. Haven matches the string exactly as sent either way, so don’t type it by hand: run the workflow once before any grant exists, and the exchange fails with a 403 that names the exact identity to add.

That exact string must be listed as an owner before the exchange succeeds, exactly like a token subject, at either of two tiers: as an owner of the registry (trust over every package in it), or as an owner of one or more of its packages (see Per-crate trust below). For registry-wide trust, add it in the registry’s Settings tab (the “Trusted publishing (CI)” card), or through the API:

POST /api/v1/registries/acme/owners
{ "subject": "https://token.actions.githubusercontent.com#repo:acme@123456/tools@7890123:ref:refs/heads/main",
"capabilities": ["publish"] }

The grant carries a capability set: what the exchanged token may do. publish alone is the safe default and what the Settings card pre-checks: a compromised workflow can then at worst ship a bad version, which stays visible and yankable, and can neither yank, nor widen access, nor weaken policy. The other capabilities are yank, annotate (vouches, holds, facts, docs uploads, attestations), and manage (settings, owners, grants, promotions); see Tokens for the full definitions. Omitting capabilities grants all four. Re-adding an identity updates its set, so grants created before capabilities existed (they behave as all four) can be narrowed the same way.

See Registries for how owners gate publishing in general.

When one repository publishes one crate, registry-wide trust is more than the pipeline needs: a compromised workflow could publish or overwrite every crate in a shared registry. Instead, add the CI identity as a package owner of just that crate, from the crate’s Settings tab (the same “Trusted publishing (CI)” form, scoped to the one crate) or through the API:

POST /api/v1/registries/acme/packages/some-crate/owners
{ "subject": "https://token.actions.githubusercontent.com#repo:acme@123456/tools@7890123:ref:refs/heads/main",
"capabilities": ["publish"] }

The exchange then accepts the identity because it owns a package of the named registry, and the minted token is bounded by that grant:

  • It publishes and yanks only the packages the identity owns, within each grant’s capability set. When the identity is trusted on several packages, the token’s scope is the union of its grants, but each package is still checked against its own grant, so one crate’s yank never leaks onto another.
  • New names stay registry-owner-gated: a per-crate CI identity can never squat a fresh name (the crates.io rule). Grants attach to existing packages only, so publish the first version yourself before trusting CI on the name.
  • Removing or narrowing a grant cuts the access immediately, not at the token’s expiry: every grant — registry-wide ones included — is checked on every request.
  • The tiers combine: an identity holding a registry-wide grant and package grants exchanges for the union of all of them, and each action is still checked against the grant that covers it — a capability granted on one package never unlocks a registry-wide lever.
  • One caveat to annotate: facts, advisories, and attestations are registry-wide surfaces with no per-package ownership, so a grant that includes annotate can record facts, raise advisories, and attest anywhere in the registry. publish alone, the default, has no such reach.

Works the same on both formats: crates and files packages alike.

The subject half is GitHub’s sub claim, and its shape depends on what triggered the workflow (OWNER/REPO carries the @id suffixes on repositories using the immutable format):

  • Branch: repo:OWNER/REPO:ref:refs/heads/main
  • Tag: repo:OWNER/REPO:ref:refs/tags/v1.0.0
  • Environment: repo:OWNER/REPO:environment:release

Pin the subject to your release branch or to a protected environment. The claim carries one concrete ref, so a tag subject names one exact tag; for tagged releases, a protected release environment is the practical pin.

The CI job sends its id_token as the bearer credential and names the registry it wants to publish to:

Terminal window
curl -sSf https://haven.example.com/api/v1/tokens/exchange \
-H "Authorization: Bearer $ID_TOKEN" \
-H "Content-Type: application/json" \
-d '{"registry": "acme"}'

On success the response is 201, and this is the only time the token is shown:

{
"ok": true,
"token": "haven_...",
"subject": "https://token.actions.githubusercontent.com#repo:acme@123456/tools@7890123:ref:refs/heads/main",
"expires_at": 1789000000
}

When the exchange fails:

  • 401: no Authorization header.
  • 403: the id_token failed verification, or its identity is neither an owner of the registry nor an owner of any of its packages (the response names the identity to add).
  • 404: the registry does not exist.
  • 501: trusted publishing is not enabled on this instance; ask the operator.

The id_token must be requested with audience haven-registry; Haven’s verifier accepts no other value. A minimal GitHub Actions release job:

name: release
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to Haven
run: |
ID_TOKEN=$(curl -sSf \
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=haven-registry" \
| jq -r .value)
TOKEN=$(curl -sSf https://haven.example.com/api/v1/tokens/exchange \
-H "Authorization: Bearer $ID_TOKEN" \
-H "Content-Type: application/json" \
-d '{"registry": "acme"}' \
| jq -r .token)
export CARGO_REGISTRIES_ACME_TOKEN="$TOKEN"
cargo publish --registry acme

The registry itself is configured in .cargo/config.toml as usual; see Publishing crates and Getting started.

The exchange works the same on a static-files registry: trust is ownership, and ownership is format-blind. Add the CI identity as an owner of the files registry, request the id_token exactly as above, and use the exchanged token on the files endpoints. The publish step of the job becomes:

Terminal window
TOKEN=$(curl -sSf https://haven.example.com/api/v1/tokens/exchange \
-H "Authorization: Bearer $ID_TOKEN" \
-H "Content-Type: application/json" \
-d '{"registry": "assets"}' \
| jq -r .token)
# Upload the version's files, then publish it.
curl -sSf -X PUT -H "Authorization: Bearer $TOKEN" \
--data-binary @dist/some-tool.tar.gz \
https://haven.example.com/r/assets/files/some-tool/1.0.0/some-tool.tar.gz
curl -sSf -X POST -H "Authorization: Bearer $TOKEN" \
https://haven.example.com/api/v1/registries/assets/files/some-tool/1.0.0/publish

The exchanged token is a normal registry token with a short life:

  • Expires 30 minutes after the exchange.
  • A capability token, never admin: it carries the union of the identity’s trust grants on that registry, registry-wide and per-package alike, with each action still checked against the grant that covers it. It is confined to the one registry the exchange named — it reads that registry and touches nothing else: no sibling registries, no organization API.
  • Revocable early from the organization’s Tokens page like any other token.
  • The mint is recorded in the audit log under the CI identity, so every release names the workflow that made it.

crates.io scopes trust per crate, to a repository and workflow file. Haven offers both tiers: per-crate trust is the same blast-radius boundary crates.io draws and the right default for one-repo-one-crate pipelines, while a registry-wide grant fits a release train that ships many crates at once. Either way, how much protection is left rests on how specific the subject string is: choose the narrowest subject your release flow allows, such as a release branch or a protected environment.