Skip to main content

07. OIDC trusted publishing

Background

Hex.pm launched Hex Trusted Publishing in 2024. The mechanism follows the pattern pioneered by PyPI (PEP 740), RubyGems.org (2023), npm Provenance (2023), and crates.io (RFC #3724, 2025): a CI job requests a short-lived OIDC token from the CI provider's OIDC endpoint, presents it to the package registry, and the registry exchanges it for a short-lived publishing credential. No long-lived API key is stored in CI secrets.

GitHub Actions flow

jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # required for OIDC token
contents: read
steps:
- uses: actions/checkout@v4
- run: mochi pkg publish --to=hex.pm

The id-token: write permission allows the job to call the GitHub Actions OIDC endpoint (ACTIONS_ID_TOKEN_REQUEST_URL) with the request token (ACTIONS_ID_TOKEN_REQUEST_TOKEN). The response is a signed JWT with the following claims relevant to Hex.pm:

ClaimExample valueMeaning
isshttps://token.actions.githubusercontent.comOIDC issuer
subrepo:owner/repo:ref:refs/heads/mainSubject
repositoryowner/repoGitHub repository
refrefs/heads/main or refs/tags/v1.0.0Branch or tag
workflow.github/workflows/publish.ymlWorkflow file path
actortamndGitHub username

Hex.pm validates the JWT against the OIDC issuer's public keys (fetched from https://token.actions.githubusercontent.com/.well-known/openid-configuration), checks the sub claim against the package's trusted publisher configuration (set up once in the Hex.pm UI or API), and issues a short-lived Hex.pm API token scoped to publishing exactly one package version.

Trusted publisher configuration

Before the first publish, the user registers the trusted publisher on Hex.pm. This requires:

  1. A Hex.pm account with ownership of the package name.
  2. Calling POST https://hex.pm/api/packages/<name>/releases/trusted_publishers with the GitHub Actions OIDC configuration:
    {
    "repository": "owner/repo",
    "workflow": ".github/workflows/publish.yml",
    "environment": null
    }

The mochi pkg publish --to=hex.pm --setup-oidc subcommand automates this one-time setup step, prompting for Hex.pm credentials locally and writing the trusted publisher record to the registry.

Token exchange sequence

GitHub Actions job
-> OIDC_REQUEST_URL?audience=hex.pm
<- signed JWT

Bridge (package3/erlang/publish/)
-> POST https://hex.pm/api/auth/oidc
{ "jwt": "<jwt>" }
<- { "api_key": "<short-lived-key>", "expires_at": "<iso8601>" }

Bridge
-> POST https://hex.pm/api/packages/<name>/releases
Authorization: <short-lived-key>
Body: <tarball bytes>
<- { "url": "...", "checksum": "..." }

The short-lived key has a TTL of 15 minutes and is scoped to publishing one version of one package. It is never written to disk.

HEX_API_KEY prohibition

The HEX_API_KEY environment variable is not checked by the bridge. If set, it is ignored. The rationale is identical to MEP-73's rejection of CARGO_REGISTRY_TOKEN and MEP-76's rejection of GEM_HOST_API_KEY: long-lived registry tokens are the primary supply-chain attack vector for package ecosystem compromise. The bridge was designed after those incidents and does not provide the unsafe path.

Users who need to publish from a non-OIDC environment (e.g., a local workstation) have two options:

  1. Configure their CI to run the publish step (the recommended path, keeps secrets out of local environments).
  2. Use mochi pkg publish --dry-run to produce the tarball and upload it manually via rebar3 hex publish with a local Hex.pm user API key (the user's responsibility, not the bridge's).

mock-hex harness

Integration tests for the publish flow use a mock-hex harness (package3/erlang/testutil/mock_hex.go) that implements the relevant Hex.pm API endpoints locally:

  • POST /api/auth/oidc: accepts any JWT and returns a mock API key.
  • POST /api/packages/:name/releases: records the upload and returns a mock response with a computed checksum.

The --dry-run flag also exercises the mock-hex harness in CI, ensuring the signing flow is tested without contacting the real registry.

Other CI providers

ProviderOIDC endpoint variableNotes
GitHub ActionsACTIONS_ID_TOKEN_REQUEST_URLPrimary supported path
GitLab CICI_JOB_ID_TOKEN_REQUEST_URLSupported; different JWT claims
BuildkiteNot yet supportedPlanned in MEP-66 N.2
CircleCICIRCLE_OIDC_TOKENPre-generated token; supported with validation

Cross-references