Workload Identity
Workload Identity, sometimes also known as "Trusted Publishing", lets CI jobs publish and fetch Hex packages without storing a long-lived key. A GitHub Actions job presents a short-lived OpenID Connect (OIDC) identity token. Hex verifies it against a workload identity you configured and returns a short-lived, scoped access token that the normal publish path accepts.
Package workload identities are for packages in the public hexpm repository. An organization's workload identities publish the packages in its private repository and fetch them, and can also publish the public packages the organization owns. See Organization workload identities.
GitHub Actions is the only supported CI provider.
How it works
- A package owner configures a workload identity that names the GitHub repository, workflow file, and environment. The environment is optional for a private repository.
- The CI job requests an OIDC token from GitHub with the audience it was configured with,
hexpmfor hex.pm. - The job exchanges that token at Hex's OAuth token endpoint for a token for one package.
- Hex verifies the token, matches a workload identity, and returns a Hex access token that expires in 15 minutes and can only publish that package.
- The job publishes with that token.
mix hex.publish and rebar3 hex publish perform steps 2, 3 and 5. They exchange a token when the job runs on a supported CI provider and no Hex credential is configured, so a workflow with the id-token: write permission needs no further steps. Without one of these build tools, a job can run steps 2 and 3 itself against the token exchange API.
The package must already exist. Workload identities can't create a public package or publish its first release, so do that once with a normal Hex account or key. This is the same for an organization's workload identities, which only publish public packages the organization already owns.
Transferring a package (mix hex.owner transfer, or "Transfer ownership" on the Owners tab) removes its workload identities, so one that a previous owner set up can't keep publishing it. The new owners add their own. Adding or removing an owner without a transfer keeps them, and a removed owner who can still run one of their workflows on GitHub can still publish the package. Hex points this out when an owner is removed, in the confirmation on the Owners tab and in the email to the package's owners. Removing an organization member works the same way for organization workload identities.
An organization that owns a public package publishes it with its own workload identities that cover public packages. Transferring the package to the organization makes them publish it, and removing the organization as an owner or transferring the package away stops them. The email to the package's owners lists the organization workload identities that start or stop publishing the package, and so does the confirmation on the Owners tab when an organization is removed.
Staged publishing
When staged publishing is on for the package or its organization, a release or documentation a workload identity publishes waits for approval.
Before you begin
You need:
- Full ownership of the Hex package (
ownerlevel, not onlymaintainer). - Two-factor authentication enabled on your Hex account, from
/dashboard/security. A workload identity grants publish rights the same way a personal key does, so adding, changing or removing one has the same requirement. - A GitHub Actions workflow that will publish, with the
id-token: writepermission.
Configure a workload identity
Open the package page and go to the "Workload identities" tab. Only full owners see the tab, and the page requires two-factor authentication.
Fill in the form:
Repository owner- GitHub user or organization login that owns the repositories, for example
acme. Case-insensitive, because GitHub logins are. Repositories- The names of the repositories whose workflows can publish, without the owner, one per line, for example
widget. Case-insensitive. A workload identity names at most 50 repositories. For a private repository, add its numeric repository ID after the name, see below. Workflow- Workflow filename only, for example
release.ymlorrelease.yaml. Hex strips any path and matches the basename of the calling workflow in each of the repositories. Case-sensitive, soRelease.ymlandrelease.ymlare different workflows. Environment- GitHub Actions environment name. Required when one of the repositories is public, and strongly recommended otherwise. A GitHub environment is where you can require reviewers, add a wait timer, or restrict which branches can deploy, so it adds a check before the workflow receives its token. GitHub offers environments in public repositories on every plan and in private repositories only on paid plans, which is why Hex doesn't require one for a private repository. When it's set, the OIDC token must name the same environment, compared case-insensitively as GitHub compares environment names. When it's blank, Hex accepts any environment or none from a private repository, and no token from a public one.
When you add a workload identity, Hex pins the immutable GitHub IDs of the owner and of each repository, so a GitHub login or repository that's deleted and created again can't inherit it. Adding it fails if an ID is missing.
The owner ID always comes from GitHub, because an owner's profile is public even when their repositories aren't. A repository ID comes from GitHub for a repository Hex can see, and Hex uses it instead of an ID typed after the name. For a private repository GitHub answers Hex with a 404, so you type the repository ID after the name, for example gadget 123456. Find it with:
$ gh api repos/OWNER/NAME --jq .id
Because the workload identity is bound to the ID and not the name, deleting and recreating a GitHub repository stops it from matching that repository, even under the same name. If that happens, remove the repository from the workload identity and save, then add it again.
The "Edit" button on a workload identity changes its repositories, workflow and environment, and for an organization's, the packages it covers. Its repository owner and role stay, so add a new workload identity for a different owner or role. A repository it already names keeps its pinned ID, so Hex only asks GitHub about the repositories a change adds, and about all of them when a change removes the environment of a workload identity that publishes, to know whether one is public. Saving fails when someone else changed the workload identity in the meantime, so review it and save again. Saving a change that removes a repository or changes the workflow or the environment revokes the tokens the workload identity issued. A change that trusts a repository, workflow or environment the workload identity didn't, or covers more packages, is emailed to everyone Hex emails about an added workload identity, with what it trusted before and what it trusts now. A change that only narrows it, such as removing a repository or naming an environment, isn't emailed. Every change is recorded in the activity log.
A package can have several workload identities, for example for several workflows, and one workload identity can name several repositories that publish with the same workflow and environment. The same GitHub repository and workflow can be attached to several packages as separate workload identities, which covers monorepos that release several packages from one repository. When one workflow run publishes several packages, exchange once per package and request a new OIDC token for each exchange, because an OIDC token can only be exchanged once.
GitHub Actions workflow
The workflow grants the id-token: write permission and publishes. Mix and Rebar3 perform the exchange:
name: Release
on:
push:
tags:
- 'v*'
permissions:
id-token: write
contents: read
jobs:
publish:
runs-on: ubuntu-latest
# The GitHub Environment the workload identity names. Required for a public
# repository.
environment: release
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
otp-version: '27'
elixir-version: '1.17'
- name: Publish with Mix
run: |
mix deps.get
mix hex.publish --yes
- name: Publish with Rebar3
run: rebar3 hex publish --yes
A job that publishes another way exchanges the token itself, against the token exchange API. Fetch token in a workflow shows that as a workflow step.
Notes:
- The job needs
permissions.id-token: writeto request an OIDC token. - The client exchanges a token only when no Hex credential is configured.
HEX_API_KEY, a key frommix hex.organization auth, and amix hex.user authsession all take precedence, so remove them from a job that should publish with its workload identity. A leftover key publishes under that key instead, and the job prints no "Authenticated with Workload Identity" line. - Workload Identity needs OTP 27 or later in the job, because the client reads the OIDC token as JSON.
- The client sets the audience it requests,
hexpmby default, from theworkload_identity_audienceconfig orHEX_WORKLOAD_IDENTITY_AUDIENCE. It readsGET /api/oidc/audienceonly to check that the server expects the same value, and requests no OIDC token when the two differ. - A job that exchanges a token itself takes the audience from its own configuration, and stops when the server reports a different one. GitHub issues an OIDC token for any audience a job asks for. A job that asks for whatever the server reports lets the server choose the audience, so reaching an untrusted API URL yields a token that another service accepts, such as another package registry or a cloud provider.
- Each OIDC token can be exchanged at most once, and Hex rejects a replayed
jti. Hex refuses OIDC tokens that are valid for more than 15 minutes, because it keeps the record of a used token for that long. GitHub's are valid for 5 minutes. The client requests a new OIDC token per exchange. - The Hex token can only publish the package named in the
scopeparameter. It isn't a general-purpose key. The client scopes it to the package it publishes, and the same token publishes that release's docs. - A workload identity for a public repository names a GitHub Environment, so only jobs in that environment can get a token. For a private repository the environment is optional, and still the only way to put protection rules in front of a publish.
Organization workload identities
An organization admin configures workload identities for the organization's private repository, and for the public packages the organization owns, on the "Workload identities" page of the organization dashboard. Every member can see the page. Adding one requires the admin role and two-factor authentication on the admin's account, and changing or removing one has the same requirements. The read role and private packages also need an active billing subscription. A write workload identity that only covers public packages doesn't, the same as an organization key publishing public packages, so the page stays usable without one and offers only public packages. Hex emails every organization admin when one is added or removed, or changed to trust more, and records every change in the organization's activity log. A package's own "Workload identities" tab lists the organization workload identities that can publish it and links to the organization page.
Each organization workload identity has a role:
read- Fetches every package in the organization's repository, including docs tarballs, from repo.hex.pm.
write- Publishes releases and docs of the packages it covers, as set by "Private packages" and "Public packages" below. With private packages it also fetches like
readand creates new packages in the organization's repository.
A write workload identity covers private packages, public packages or both:
Private packages- Packages in the organization's repository, checked by default. The list of package names limits which ones it covers. Leave it empty to cover every package in the repository. A name in the list doesn't have to exist yet, so the workflow can create that package.
Public packages- Public packages on hex.pm that the organization owns. Leave the list empty to cover every public package the organization owns, including packages it becomes an owner of later. The list can only name public packages the organization owns when the workload identity is added. It never creates a public package, so publish a package's first release with a user account or a key, and transfer it to the organization by adding the organization on the package's Owners tab or with
mix hex.owner transfer. Each public release shows the GitHub repository, workflow and actor that published it in its Provenance card and in the release API, also when the repository is private.
The read role covers the organization's repository only, because public packages need no credentials to fetch.
The other fields are the same as for a package workload identity. The write role needs at least one repository and a workflow, and an environment when a repository is public. The read role can leave the repositories and the workflow empty, and never needs an environment:
- An empty workflow matches every workflow in the repositories, including test workflows. Anyone who can push a workflow file to one of them can then fetch, the same as with an organization key stored as a repository secret, so set an environment with protection rules when that matters.
- No repositories match every repository owned by the repository owner, pinned by the owner's GitHub ID. Anyone who can create a repository under that owner, or push a workflow file to one of its repositories, can then fetch, the same as with an organization key stored as a GitHub organization secret available to all repositories. The workflow and environment must be empty too, because whoever creates a repository also names its workflows and environments.
The write role always names its repositories, because publishing reaches everyone who depends on the packages, and an environment can't narrow a workload identity that matches every repository: whoever creates a repository also creates its environments and their protection rules.
A common setup is one read workload identity without repositories, so every repository in your GitHub organization can fetch private dependencies, and one write workload identity naming the repositories that release packages with the same release workflow and environment.
A public package is published by its own package workload identities and by the workload identities with public packages of the organizations that own it. Hex checks at the exchange and again at publish that the organization is a full owner of the package, the same level a user needs to add a package workload identity. Removing the organization as an owner or transferring the package away stops its workload identities from publishing the package at once, and the identities stay on the organization's page. Private packages only use organization workload identities.
Adding or removing a workload identity with public packages also emails the other owners of the public packages it covers, one email per person naming their packages, the same as adding a package workload identity emails every package owner. A change emails the owners of the public packages it starts publishing, and of those it keeps publishing when it trusts a repository, workflow or environment it didn't.
Neither role can retire releases, revert, or change owners, keys, members, or settings. The organization's single sign-on and two-factor authentication requirements don't apply to organization workload identities, the same as for organization keys.
mix deps.get fetches an organization's packages with a workload identity. It exchanges a token when the job has the id-token: write permission, and neither an organization key nor an authenticated user is configured. A fetch token comes from the scope of the organization's repository:
scope=repository:ORG
Send the access token to repo.hex.pm as Authorization: Bearer <token>. It can't call the Hex API.
mix hex.publish and rebar3 hex publish scope the token to the package they publish, in the organization's repository when the project names the organization. Publishing or creating a private package uses a package scope in the organization's repository, the same as for a package workload identity:
scope=package:ORG/PACKAGE
Hex matches the organization's write workload identities with private packages whose list covers the name. A package created this way has no owners, the same as one created with an organization key. A job that fetches private dependencies and then publishes exchanges twice, once per scope, with a new OIDC token each time.
To publish a public package the organization owns, use the public repository's package scope:
scope=package:hexpm/PACKAGE
Hex matches the package's own workload identities, and the write workload identities with public packages of the organizations that own it whose list is empty or names the package. A name no public package has gets the same access_denied as no matching workload identity.
Removing a workload identity stops its tokens at the Hex API on the next request. repo.hex.pm verifies tokens without asking Hex, so a fetch token keeps working there until it expires, at most 15 minutes later. A lapsed billing subscription behaves the same, since Hex checks billing during the exchange for the organization's repository. Publishing public packages doesn't need an active subscription.
Fetch token in a workflow
When something other than Mix fetches the organization's packages, such as a container build or a tool that reads repo.hex.pm directly, the job exchanges the token:
permissions:
id-token: write
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Exchange a fetch token
env:
HEX_API_URL: https://hex.pm/api
AUDIENCE: hexpm
run: |
set -euo pipefail
# The audience comes from this job, never from the server. Stop
# when the server expects a different one.
SERVER_AUDIENCE=$(curl -fsS "$HEX_API_URL/oidc/audience" | jq -r .audience)
if [ "$SERVER_AUDIENCE" != "$AUDIENCE" ]; then
echo "Server expects audience $SERVER_AUDIENCE, not $AUDIENCE" >&2
exit 1
fi
OIDC_TOKEN=$(curl -fsS \
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AUDIENCE}" \
| jq -r .value)
RESPONSE=$(curl -fsS -X POST "$HEX_API_URL/oauth/token" \
-H "content-type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
--data-urlencode "assertion=$OIDC_TOKEN" \
--data-urlencode "scope=repository:ORG")
ACCESS_TOKEN=$(printf '%s' "$RESPONSE" | jq -r .access_token)
echo "::add-mask::$ACCESS_TOKEN"
echo "HEX_ACCESS_TOKEN=$ACCESS_TOKEN" >> "$GITHUB_ENV"
Replace ORG with the organization name. Later steps send the token to repo.hex.pm as Authorization: Bearer $HEX_ACCESS_TOKEN. Swap the scope for package:ORG/PACKAGE to publish in the organization's repository instead, or package:hexpm/PACKAGE for a public package it owns.
Token exchange API
Read the audience the server expects:
GET /api/oidc/audience
{"audience":"hexpm"}
This endpoint checks an audience the job already knows. The job sets its own audience, hexpm for hex.pm, compares it with the response, and requests no OIDC token when the two differ. A job that trusts the response instead lets the server choose the audience, and an untrusted server can name any service. Mix and Rebar3 take the audience from the workload_identity_audience config, or HEX_WORKLOAD_IDENTITY_AUDIENCE.
Exchange a CI OIDC token for a Hex access token with the JWT bearer grant (RFC 7523) on the standard OAuth token endpoint:
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&
assertion=<github-oidc-jwt>&
scope=package:hexpm/PACKAGE
No client_id is needed, because the OIDC token is the credential. scope names exactly one package as package:REPOSITORY/PACKAGE. An organization workload identity uses its organization's repository (package:ORG/PACKAGE) for a private package and package:hexpm/PACKAGE for a public one, or requests repository:ORG to fetch. Successful response:
{
"access_token": "<hex-access-token>",
"token_type": "bearer",
"expires_in": 900,
"scope": "package:hexpm/PACKAGE"
}
Errors use OAuth-style bodies (error, error_description), for example missing fields (invalid_request), a malformed or missing scope (invalid_scope), bad or replayed OIDC tokens (invalid_grant), or no matching workload identity (access_denied). An unknown package, repository, or organization returns the same access_denied as no matching workload identity. A matching workload identity of an organization without an active billing state also gets access_denied for the organization's repository, with a description saying so.
The grant is public, since the OIDC token is the credential. Exchanges are limited to 1,000 a minute per address, far above what CI runners sharing an address send, and beyond that the endpoint answers 429. Exchanges that fail after the OIDC token is verified count toward a limit per GitHub repository. Once a repository exceeds it, the endpoint answers 429 with slow_down for that repository only. Tokens from rejected events and replayed tokens don't count, because a pull request from a fork can produce them.
To revoke a Hex token before it expires, for example at the end of the job, send it to the standard revocation endpoint (RFC 7009), again without a client_id:
POST /api/oauth/revoke
Content-Type: application/x-www-form-urlencoded
token=<hex-access-token>
Security model
- Hex verifies the GitHub OIDC signature with discovery and JWKS, rejects
noneand HMAC algorithms, and checksiss,aud,exp,nbf, andiat. - Matching requires one of the configured repositories, the workflow basename from a workflow ref inside that repository, the immutable
repository_owner_idandrepository_id, and the environment when one is configured. A token without arepository_idclaim never matches. A workload identity that can publish and has no environment matches no token whoserepository_visibilityclaim ispublic. The workflow is compared exactly, including casing. The repository, owner, and environment are compared case-insensitively, and the repository and owner are pinned by their immutable GitHub IDs. A reusable workflow from another repository doesn't satisfy the workflow match by having the same basename. - Pinning the repository ID as well as the owner ID matters inside an organization. If the configured repository is deleted, anyone who can create a repository in that organization could otherwise recreate the name, add the configured workflow, and get a token. The owner ID alone wouldn't stop that.
- Hex tokens issued this way expire after 15 minutes, cover one package or one organization repository, and are attributed to the workload identity. The release has no publishing user, and the audit log records the publish.
- A job sets the audience it requests, and
GET /api/oidc/audienceonly confirms that the server expects the same value. GitHub issues an OIDC token for any audience a job asks for, so a job that took the audience from the server could be made to mint a token for another service, such as another package registry or a cloud provider. A pinned audience bounds what reaching an untrusted API URL costs: the token is only valid for hex.pm, and only for the workload identity's scope. It remains a usable hex.pm token, replayable there within its 15-minute lifetime. - Hex rejects tokens from
pull_request_targetandworkflow_runworkflows. Aworkflow_runrun gets write permissions even when a pull request from a fork triggered the run that started it, and no OIDC claim says whether one did. - Adding, changing or removing a package workload identity requires full package ownership and two-factor authentication, because a workload identity grants publish rights the same way a personal key does. Hex emails every package owner when a workload identity is added or removed, or changed to trust more, and the email can't be turned off. Transferring the package removes its workload identities. An organization workload identity with public packages emails the owners of the public packages it covers in the same way. The CI exchange and publish don't require two-factor authentication, because the grant has no interactive user.
- Hex records an allowlisted snapshot of the verified OIDC claims (repository, workflow ref, commit SHA, run id, and similar) on the Hex token and attaches it to the release published with that token. The release API exposes it as
oidc_claims. The package page shows it in a Provenance card that links to the source commit, build file, branch or tag, environment, triggering actor, and workflow run, so anyone using the package can see that a release was published with Workload Identity and which workflow produced it.
Limitations
- Only GitHub Actions can act as a workload identity. GitLab, CircleCI, and custom OIDC issuers aren't supported.
- Workload identities can't create a package or publish its first release from CI, except an organization workload identity creating a package in the organization's repository. Publish a public package once manually, then attach a workload identity, or transfer the package to an organization whose workload identities cover public packages.
Troubleshooting
- The form reports the GitHub repository could not be resolved: the GitHub owner doesn't exist or is misspelled. Hex could read neither the repository nor the owner profile.
- The form reports that a repository needs its repository ID after the name: Hex couldn't see the repository, so it can't pin the ID itself. For a private repository, type its ID after the name. For a public one, this usually means the repository name is misspelled, because GitHub answers 404 both for a repository that doesn't exist and for one Hex can't see, so a typo looks the same as a private repository. A wrong repository name combined with a repository ID you type is saved but never matches, and the exchange returns no matching workload identity.
- The form reports that a repository was deleted and created again on GitHub: GitHub reports a different ID for a repository the workload identity already names. Remove the repository and save, then add it again to pin the new repository.
- The form reports that a repository was renamed or transferred on GitHub: GitHub redirects the old name to the repository's new location, and tokens name the repository by its new name. Replace the old name with the new one, under the new owner if it was transferred.
- The form reports that the environment is required because a repository is public: a workload identity that can publish from a public repository must name a GitHub environment. Create one under the repository's Settings, Environments, add protection rules such as required reviewers, set
environment:on the publishing job, and enter the environment's name in the form. - The form reports that public package names must be public packages the organization owns: a listed name isn't a public package, or the organization isn't one of its owners. Publish the package's first release and transfer it to the organization first, or remove the name from the list.
- The form reports that private packages or the read role need an active billing subscription: the organization's subscription isn't active. Set up billing on the organization's Billing page, or add a
writeworkload identity that only covers public packages. - The form reports that GitHub could not be reached: GitHub was unreachable, rate-limited, or answered with something unexpected. Nothing was stored, so you can submit the same form again.
- The form reports too many attempts in the last hour: each account can save workload identities 20 times an hour, and the GitHub calls those saves make count towards 100 an hour: one per repository looked up, and on an add one more for the owner's profile. A save that doesn't fit isn't counted. Try again later.
- The exchange returns no matching workload identity: check the package name, Hex repository, GitHub
owner/repo, workflow filename, and environment against the configured workload identity, including the exact casing of the workflow filename. The workflow file that requests the OIDC token must be in the configured repository. A workload identity that publishes without an environment stops matching a repository that's made public, so add an environment to it. - The exchange rejects the OIDC token: confirm
id-token: write, audiencehexpm, and that you aren't reusing a JWT that was already exchanged. With Mix or Rebar3, also confirm the job runs OTP 27 or later, which Workload Identity needs. - Mix or Rebar3 publishes without Workload Identity, or asks for credentials: the client exchanges a token only when it detects a supported CI job and no Hex credential is configured. Remove
HEX_API_KEYand any key frommix hex.organization authfrom the job. A successful exchange prints "Authenticated with Workload Identity". - The exchange rejects a token from a
pull_request_targetorworkflow_runworkflow: both events run with the base repository's permissions when a pull request from a fork triggered them, so code from a fork could request an OIDC token. Publish frompush,release, orworkflow_dispatchinstead. - Publish fails with package ownership or scope errors: the Hex token only covers the package named in the exchange. Mix and Rebar3 scope the token to the package they publish, so check that the workload identity covers that package. A token from a manual exchange needs the scope of the package being published.
- The "Workload identities" tab is missing, or the token endpoint returns
unsupported_grant_type: Workload Identity may be disabled on that Hex deployment, or your account isn't a full owner of the package.