Trusted publishing with workload identity
Forge can authenticate a CI job with its short-lived OIDC identity token instead of a stored API key. The token is validated against the configured issuer’s discovery document and JWKS, matched against explicit claim rules, and mapped to an ordinary Forge RBAC role. A matching trusted publisher can add package-scoped write or delete rights on one hosted repository. No user or browser session is created.
A job uses its token in one of two ways:
- Token exchange (recommended). The job sends the token once to
POST /api/v2/tokenand gets a short-lived workload API key back, exactly as nuget.org’s trusted publishing works. It publishes withdotnet nuget push --api-key <key>and restores with the key as the Basic password of the usernameoidc. npm performs its own native exchange automatically. Each token is redeemed once. - Direct token. The job presents the token itself on every request, as the
Basic password (username
oidc), theX-NuGet-ApiKeyor a Bearer token.
The implementation supports workload-only discovery documents containing just
issuer and jwks_uri, including GitHub Actions. Interactive OIDC login keeps
its stricter discovery requirements and remains a separate realm kind.
Security model
A workload token is accepted only when all of these checks pass:
- RSA signature and an explicit, non-empty
kid; a missingkidfails before metadata fetch. An unknownkidmay claim one cooldown-gated JWKS refresh and then fails closed, so random-key traffic cannot force a refetch per request; - exact configured issuer and at least one exact allowed audience (wildcards are rejected);
- required
exp,iat, andsub, plusnbfwhen present; iatis not in the future, token age is withinmax_token_age_seconds, and the declaredexp - iatlifetime is also within that bound;- optional
azpis inallowed_authorized_partieswhen that list is configured; - the first matching rule has at least one explicit claim condition; catch-all
rules are rejected. A rule must also contain a workload identity condition:
issuer/audience/time/ref/environment conditions alone are rejected because
they can match unrelated jobs. For GitHub Actions, every rule must include
both
repository_idandrepository_owner_id; GitLab.com requires a numeric project ID (job_project_idorproject_id) and namespace ID (job_namespace_idornamespace_id). Mutable paths andsubare optional extra boundaries, not identity anchors. Any other issuer (self-managed GitLab, GitHub Enterprise Server, other CI systems) must pin one of the built-in stable identity claims (sub,repository_id,repository_owner_id,repository,repository_owner,job_workflow_ref,workflow_ref,project_id,job_project_id,namespace_id,job_namespace_id,project_path,namespace_path) or a claim the realm declares inidentity_claims(for a CI system with its own workload identifier); claims such asactororevent_namecan be shared by unrelated jobs and never count as an identity. The one exception is an issuer-wide rule ("issuer_wide": true, Every project of this issuer in the web UI), the opt-in for a self-managed issuer whose workloads all may read. It matches every token of the realm’s issuer without an identity condition; conditions on context such asref_protectedmay narrow it, and none is required. Forge refuses it for GitHub Actions and GitLab.com, whose tokens anyone can mint, and a self-managed GitLab with open sign-up is just as public: every account that can create a project and run a pipeline, in a personal namespace or a fork too, gets the role. Rules are first-match, so an issuer-wide rule must come after every other rule. A workload it matched holds its role’s grants capped atread, with the CI token, an exchanged key and the registry tokens minted from either: awriteordeletegrant the role holds, or is given later, never reaches every project. Writes stay pinned to a workload identity: a rule for the project ahead of the issuer-wide one picks a role of its own, and a trusted publisher still addswritefor its own project on top of the issuer-wide read; and - a realm rule must accept the token and map it to a role; a trusted publisher
alone never authenticates a workload. The role continues to grant base access,
commonly
readon hosted and proxy repositories, while a trusted publisher adds write or delete rights only for its hosted repository and matching canonical package IDs.
Positive signature-verification results may be cached only when the token has a
jti. The cache key includes the complete token digest and validation-policy
digest, and its TTL is the smaller of positive_cache_seconds and the remaining
time to exp. Claim rules and role lookup still run for every request.
In the direct-token flow a valid token can be reused for the multiple HTTP
requests a native NuGet operation makes until exp; jti is not a one-time
consumption ledger there. Keep provider token lifetimes short and set
max_token_age_seconds accordingly.
Every successful workload publish — and every unlist, relist and delete a
workload performs — appends a trusted_publish_audit row (its action says
which) in the same PostgreSQL transaction as the package change, behind the
same realm and publisher fences described below. It snapshots repository,
realm, issuer, role, canonical package ID/version, validated token iat, and
SHA-256 digests of subject and jti; raw claims and bearer tokens are not
retained. When a trusted publisher authorized the write, the audit also records
its id and name snapshot; both are null when the role authorized it. Immediately
before commit, Forge locks the current realm and role and compares the complete
normalized realm-config digest with the config that authenticated the request.
It also rechecks matched publisher definition digests. A realm edit revokes that
realm’s keys; editing or deleting a publisher withdraws only its rights from
already issued keys and registry tokens. Repository grants and selectors are
enforced before this hook; they are not re-evaluated by the audit insert itself.
The table has no cascading foreign keys and a database trigger rejects UPDATE
or DELETE, so ordinary application-level repository/realm/role removal preserves
the snapshotted names. This is an internal append-only application ledger, not a
tamper-evident or external transparency log: a database owner can still disable
the trigger or truncate the table, and the row does not capture every provider
claim, matched rule, source commit, or ref. Externally verifiable attestations
remain future work.
Token exchange
POST /api/v2/token takes the job’s token as Authorization: Bearer, with
nuget.org’s request body (optional here):
POST /api/v2/token
Authorization: Bearer <CI OIDC token>
Content-Type: application/json
{"username": "oidc", "tokenType": "ApiKey"}
Add a trusted publisher to the hosted releases repository. GitLab’s protected
environment deploy access determines which jobs may use nuget-release; Forge
checks the signed claims and does not make that provider-side decision:
{
"name": "widgets-release",
"description": "Protected GitLab release environment for Acme.Widgets",
"realm": "gitlab-com",
"packages": { "includes": ["Acme.Widgets.*"], "excludes": [] },
"actions": ["write"],
"conditions": [
{ "claim": "job_project_id", "any_of": ["1001"] },
{ "claim": "job_namespace_id", "any_of": ["2001"] },
{ "claim": "environment", "any_of": ["nuget-release"] },
{ "claim": "environment_protected", "any_of": ["true"] }
]
}
{"apiKey": "forge_wif.…", "expires": "2026-10-21T15:04:05Z"}
- The token passes every check above (the realm’s claim rules pick the role),
and it must carry a
jti: each(issuer, jti)is exchanged once, so a token captured from a job log cannot be redeemed again. A second exchange, a token withoutjti, an API key in place of a token and a token of an issuer no realm trusts all get 401. usernameis optional and, when given, must beoidc;tokenType, when given, must beApiKey(400 otherwise). Identity comes only from the verified token, never from the body.- The key lives for the realm’s
api_key_ttl_seconds(60 to 86400, default 3600, nuget.org’s lifetime), independent of the token’s ownexp. That matters for GitLab, whose token lives as long as the job’s timeout. - The key authorizes as the token’s workload: same realm, role, matched trusted
publishers and audit identity. A publish with it records the exchanged
token’s subject and
jtidigests and itsiatintrusted_publish_audit. - Revocation: a key is honoured only while its realm is enabled with the
very configuration it was issued under. Editing anything in the realm
(rules, audiences, lifetimes, its name) or disabling or deleting it revokes
all of its outstanding keys on the next request, for good: enabling the
realm again or reverting the edit does not bring them back. A key issued
under the new configuration works on every instance at once, also on
those that have not reloaded the realm yet. Deleting the role leaves the
key without role rights. Editing or deleting a trusted publisher similarly
revokes only that publisher’s rights from existing keys and registry tokens;
the realm’s other keys keep working. A registry token
docker login -u oidcgot with a key stops working within 10 seconds of these changes, and expires no later than the key. Forge stores only SHA-256 digests of the key, subject andjti(tableworkload_api_key), and the auth reaper deletes a row once the key has expired and the token has been expired for 15 minutes (the most clock skew a realm accepts, plus a margin). Whether a token has expired is decided on the database’s clock, so an instance whose clock runs behind it cannot redeem a token twice. - A key starts with
forge_wif., which no user API token can (user tokens are URL-safe base64, without a dot). Secret scanners can matchforge_wif\.[A-Za-z0-9_-]{43}. - The key is accepted as
X-NuGet-ApiKey(--api-key, or theNUGET_API_KEYenvironment variable from .NET SDK 10.0.300), as a Bearer token, or as the Basic password of the usernameoidc.
This implements the exchange profile of the OpenSSF Trusted Publishers
guidance: a short-lived, repository-controlled publish credential and
one-time jti redemption. The direct-token flow stays available for clients
that cannot run an exchange step.
The oidc username
oidc is a reserved account name. HTTP Basic with the username oidc (in
any case) is only ever checked as a workload credential: the password must be
a token of an enabled workload_identity realm or a workload API key. Anything
else, including a user’s API token, is refused with 401, and it never reaches
the password realms, so it is never forwarded to a credential-forwarding
upstream. No account can be created, bootstrapped or provisioned under the name
(it is no UPN either), and the browser login refuses it.
Other Basic usernames still work with a direct token, as before. Prefer oidc:
it is the one name that can never mean an account.
GitHub Actions
Create a workload_identity realm under Admin → Authentication. Give its
reader role read access to this hosted repository and the feeds the project
restores from. Its realm rule accepts the project’s jobs and maps them to that
role. Use stable numeric identity claims rather than repository/owner names,
which can be renamed.
Also pin which workflow may publish and how it was triggered:
job_workflow_ref (e.g.
acme/widgets/.github/workflows/release.yml@refs/heads/main) keeps any other
workflow in the repository — including one a contributor adds in a pull
request — from minting a usable token, and event_name (e.g. push or
release) excludes pull_request_target, workflow_dispatch and similar
triggers you did not intend.
{
"issuer": "https://token.actions.githubusercontent.com",
"allowed_audiences": ["https://forge.example.com"],
"clock_skew_seconds": 60,
"max_token_age_seconds": 900,
"positive_cache_seconds": 60,
"rules": [
{
"role": "ci-reader",
"conditions": [
{ "claim": "repository_id", "any_of": ["987654321"] },
{ "claim": "repository_owner_id", "any_of": ["1234567"] }
]
}
]
}
Then add a trusted publisher on the hosted repository at Admin → Repositories → releases → Trusted publishers. This pins publishing to the release workflow while the realm rule above keeps all jobs in the repository eligible for read access:
{
"name": "widgets-release",
"description": "Protected GitHub release workflow for Acme.Widgets packages",
"realm": "github-actions",
"packages": { "includes": ["Acme.Widgets.*"], "excludes": [] },
"actions": ["write"],
"conditions": [
{ "claim": "repository_id", "any_of": ["987654321"] },
{ "claim": "repository_owner_id", "any_of": ["1234567"] },
{ "claim": "ref", "any_of": ["refs/heads/main"] },
{
"claim": "job_workflow_ref",
"any_of": ["acme/widgets/.github/workflows/release.yml@refs/heads/main"]
},
{ "claim": "event_name", "any_of": ["push"] }
]
}
The workflow needs only read access to the checkout plus permission to request
an OIDC token. The official NuGet/login action requests it with the audience
configured above and exchanges it at Forge’s token endpoint. Push to the hosted
repository’s publish URL with --no-service-endpoint: the client then sends a
single PUT carrying the key, without first fetching the private service
index, so no source credentials are needed for the push.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
- name: Exchange the OIDC token for a Forge API key
uses: NuGet/login@v1
id: forge
with:
user: oidc
audience: https://forge.example.com
token-service-url: https://forge.example.com/api/v2/token
- name: Restore and build
shell: bash
env:
# `forge` is the repository's nuget.config source (no credentials in it).
NuGetPackageSourceCredentials_forge: Username=oidc;Password=${{ steps.forge.outputs.NUGET_API_KEY }}
run: |
dotnet restore
dotnet pack -c Release -o artifacts --no-restore
- name: Publish
shell: bash
run: >-
dotnet nuget push "artifacts/*.nupkg"
--source https://forge.example.com/repository/releases/package
--no-service-endpoint
--api-key "${{ steps.forge.outputs.NUGET_API_KEY }}"
NuGetPackageSourceCredentials_<name> applies to the source of that name in
nuget.config; the name must be a valid environment-variable name (no -),
and NuGet ignores a malformed value without a message.
Without the exchange
Pass the token itself as the NuGet API key and the source password:
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
- name: Request Forge identity token
id: forge-token
shell: bash
run: |
response="$(curl --fail --silent --show-error \
-H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https%3A%2F%2Fforge.example.com")"
token="$(jq -r '.value' <<<"${response}")"
echo "::add-mask::${token}"
echo "token=${token}" >> "${GITHUB_OUTPUT}"
- name: Publish
shell: bash
env:
FORGE_TOKEN: ${{ steps.forge-token.outputs.token }}
NUGET_CONFIG: ${{ runner.temp }}/forge-nuget-${{ github.run_id }}-${{ github.run_attempt }}.config
run: |
umask 077
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?><configuration><packageSources><clear /></packageSources></configuration>' > "${NUGET_CONFIG}"
chmod 600 "${NUGET_CONFIG}"
dotnet nuget add source \
https://forge.example.com/repository/releases/index.json \
--name forge-oidc \
--username oidc \
--password "${FORGE_TOKEN}" \
--store-password-in-clear-text \
--configfile "${NUGET_CONFIG}"
dotnet nuget push artifacts/*.nupkg \
--source forge-oidc \
--api-key "${FORGE_TOKEN}" \
--configfile "${NUGET_CONFIG}"
- name: Remove temporary Forge credentials
if: always()
shell: bash
env:
NUGET_CONFIG: ${{ runner.temp }}/forge-nuget-${{ github.run_id }}-${{ github.run_attempt }}.config
run: |
if [[ -f "${NUGET_CONFIG}" ]]; then
dotnet nuget remove source forge-oidc --configfile "${NUGET_CONFIG}" >/dev/null 2>&1 || true
rm -f "${NUGET_CONFIG}"
fi
See GitHub’s OIDC reference for the current claim set and subject customization options.
npm
npm 11.5.1 and later can exchange a CI identity token automatically during
npm publish, without a stored _authToken or a curl step. npm 11.21 and
later also supports the exchange for npm dist-tag. Use a current npm release
with this support; pnpm, Yarn and Bun do not perform npm’s exchange.
Configure a workload_identity realm under Admin → Authentication, using
the GitHub Actions or GitLab claim rules shown in this document, but set
allowed_audiences to ["npm:forge.example.com"]. The audience is
npm:<registry hostname>, not the registry URL, port or repository path.
If the realm also serves NuGet, add this audience alongside its existing one.
Give the mapped role write on the hosted npm repository and, when publishing through a group, read on the group and write on its writable member. Package selectors on that role can restrict the package namespace.
GitHub Actions
Use the GitHub issuer and immutable repository/owner IDs from the
GitHub Actions configuration above, with the npm audience.
Do not configure NODE_AUTH_TOKEN or _authToken:
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '26'
- run: npm ci
- run: npm publish --provenance=false --registry https://forge.example.com/repository/npm-all/
npm requests the GitHub job’s ID token with the npm:forge.example.com
audience, exchanges it at Forge and uses the resulting key for the command.
For private dependencies, npm ci still needs separate read credentials;
the automatic exchange is for publishing, not installing.
GitLab CI, including self-hosted GitLab
Use the GitLab realm configuration below with the npm audience.
GitLab supplies the token through NPM_ID_TOKEN:
publish:
image: mirror.gcr.io/library/node:26
timeout: 10m
id_tokens:
NPM_ID_TOKEN:
aud: npm:forge.example.com
script:
- npm ci
- npm publish --registry https://forge.example.com/repository/npm-all/
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_REF_PROTECTED == "true"'
Self-hosted GitLab works the same way: set the realm’s issuer to the exact
iss in that instance’s job tokens, for example https://gitlab.example.com,
instead of https://gitlab.com. Pin numeric project and namespace IDs in the
claim rules, plus protected-ref or environment restrictions. Older GitLab
versions without job_project_id / job_namespace_id can use project_id /
namespace_id. Forge must be able to reach that issuer’s HTTPS discovery
document and JWKS and trust its TLS certificate, including for a private
instance. Do not enable allow_http_issuer for production. Set
max_token_age_seconds to cover the job timeout, as described below.
Exchange and limitations
npm calls POST /-/npm/v1/oidc/token/exchange/package/<escaped-name> at the
host root, even when the registry is /repository/npm-all/. Reverse proxies must
forward this root path to Forge, not only /repository/. The answer is
{"token":"forge_wif.…"}; invalid requests or credentials receive a JSON
error and 400 or 401 (temporary authentication failures receive 503).
Both exchange endpoints share the same one-redemption-per-(issuer, jti)
ledger, realm checks and api_key_ttl_seconds. The named package does not
scope the issued key: the realm’s role and matched trusted publishers authorize
its use. The exchange has no repository, so a key may be issued even when the
workload has no publisher for that package; its publish is refused with 403.
npm silently falls back to configured credentials when the exchange fails;
omit stored tokens in jobs intended to require trusted publishing. Forge
does not serve npm’s package-visibility endpoint. npm 11.5.1 can enable
provenance automatically for public GitHub repositories without checking that
endpoint, so pass --provenance=false to npm publish for those clients.
Forge rejects provenance statements until attestations can be stored.
GitLab CI
GitLab CI with workload identity walks through this setup step
by step, from the role to the .gitlab-ci.yml;
CI with service accounts covers pipelines that
cannot use workload identity.
Give the realm’s reader role read access to the hosted repository and the
feeds the project restores from. The realm rule accepts jobs from the project,
so every pipeline can restore; a separate trusted publisher limits writes to
the protected release environment and selected packages.
{
"issuer": "https://gitlab.com",
"allowed_audiences": ["https://forge.example.com"],
"clock_skew_seconds": 60,
"max_token_age_seconds": 39600,
"positive_cache_seconds": 60,
"rules": [
{
"role": "ci-reader",
"conditions": [
{ "claim": "job_project_id", "any_of": ["1001"] },
{ "claim": "job_namespace_id", "any_of": ["2001"] }
]
}
]
}
GitLab gives an ID token the job’s timeout as its lifetime, so
max_token_age_seconds must be at least the longest timeout of a job that
publishes. The default, 39600 (11 hours), covers jobs up to 11 hours; set a
lower value when your release jobs are shorter. The exchanged key lives for the
realm’s api_key_ttl_seconds instead, whatever the job’s timeout.
Ask GitLab to mint an ID token with that exact audience, exchange it, and push
to the publish URL with --no-service-endpoint (see GitHub Actions above):
publish:
environment:
name: nuget-release
id_tokens:
FORGE_ID_TOKEN:
aud: https://forge.example.com
script:
- >-
export NUGET_API_KEY="$(curl --fail --silent --show-error
-X POST https://forge.example.com/api/v2/token
-H "Authorization: Bearer $FORGE_ID_TOKEN"
-H 'Content-Type: application/json'
-d '{"username":"oidc","tokenType":"ApiKey"}' | jq -r .apiKey)"
- export NuGetPackageSourceCredentials_forge="Username=oidc;Password=$NUGET_API_KEY"
- dotnet restore
- dotnet pack -c Release -o artifacts --no-restore
- >-
dotnet nuget push "artifacts/*.nupkg"
--source https://forge.example.com/repository/releases/package
--no-service-endpoint
--api-key "$NUGET_API_KEY"
Without the exchange
publish:
id_tokens:
FORGE_ID_TOKEN:
aud: https://forge.example.com
script:
- export NUGET_CONFIG="$CI_PROJECT_DIR/.forge-nuget-$CI_JOB_ID.config"
- umask 077
- printf '%s\n' '<?xml version="1.0" encoding="utf-8"?><configuration><packageSources><clear /></packageSources></configuration>' > "$NUGET_CONFIG"
- chmod 600 "$NUGET_CONFIG"
- >-
dotnet nuget add source
https://forge.example.com/repository/releases/index.json
--name forge-oidc
--username oidc
--password "$FORGE_ID_TOKEN"
--store-password-in-clear-text
--configfile "$NUGET_CONFIG"
- >-
dotnet nuget push artifacts/*.nupkg
--source forge-oidc
--api-key "$FORGE_ID_TOKEN"
--configfile "$NUGET_CONFIG"
after_script:
- export NUGET_CONFIG="$CI_PROJECT_DIR/.forge-nuget-$CI_JOB_ID.config"
- dotnet nuget remove source forge-oidc --configfile "$NUGET_CONFIG" >/dev/null 2>&1 || true
- rm -f "$NUGET_CONFIG"
See GitLab’s ID-token authentication reference for provider claim semantics.
In the direct-token examples the temporary Basic source is intentional. The
.NET NuGet client resolves the service index before it sends the push API key,
and --api-key is not attached to that initial GET (pushing to the publish URL
with --no-service-endpoint, as the exchange examples do, skips that GET). Supplying the same short-lived OIDC token as the source
password authenticates both phases; the cleanup step removes it even after a
failed push. The examples use a job-unique, mode-0600 config rather than the
runner’s default/global NuGet.Config, so concurrent jobs cannot reuse it and
ordinary failure cleanup removes the bearer. Cleanup is best-effort: hard runner
termination can leave the file until the token expires, so prefer ephemeral
runners. An anonymous_read repository can omit this temporary source and push
directly by URL, but that deliberately exposes the repository’s read-side
metadata according to the configured anonymous policy.
Operational guidance
- Give each CI trust policy its own repository-scoped role. Forge rejects WIF
rules that map to the built-in global
adminrole. - Keep all rules for one issuer in one enabled realm. Forge rejects duplicate enabled WIF issuers so routing cannot depend on realm order.
- Grant a selector-scoped
writelevel (which includesread) on the intended hosted repository, using an include/exclude package selector such asAcme.Widgets.*. Thereadit includes serves a private feed’s service-index GET; it does not widen access beyond that selector on package-addressed routes. - Prefer numeric provider IDs and add a protected ref/environment condition.
A ref or environment is an additional boundary, not a workload identity by
itself. Forge does not require one: a rule that pins only the repository or
project lets any branch and any workflow of it publish (bypassing branch
protection), including
pull_request_targetruns and, on GitLab, a pipeline for a fork’s merge request run in the parent project. On GitHub addjob_workflow_refplus a protected ref or anenvironmentto every publishing rule; GitLab tokens have nojob_workflow_ref, so there addref_protected = trueor anenvironment, and prefer thejob_*ids:project_idandnamespace_idname a merge request’s source project. - Use a dedicated, exact audience for Forge; never reuse a broad cloud audience.
- Set
positive_cache_seconds = 0to disable the per-token positive verdict cache. Discovery and JWKS have their own caches (JWKS defaults to one hour), so this does not force signing-key revocation to be fetched on every request. The positive verdict cache itself never extends past its configured TTL or token expiry. - When the issuer’s discovery document or JWKS cannot be fetched (a timeout,
a refused connection, a 5xx) and is not cached yet, a workload token
cannot be checked: the exchange, the feeds and
/v2/tokenanswer 503 withRetry-Afterrather than 401, so clients that retry do. This happens on a cold cache only: after a restart, on a new replica, or when the JWKS cache expires (hourly by default) during the issuer’s outage. - Treat a token as a bearer credential until
exp, and a workload key until itsexpires: mask both in CI logs and do not persist them as an artifact, cache entry, or repository secret. Exchange right before restoring or publishing; a key outlives the job only up toapi_key_ttl_seconds, so lower it when your jobs are short. - To revoke every outstanding workload key of a realm at once, edit or disable the realm; the keys stay revoked when you enable it again.