GitLab CI with workload identity
This guide connects a GitLab project’s NuGet builds to Forge without storing a secret in GitLab. Every job of the project can restore from Forge’s feeds, and only the release job can push, and only the packages you name:
| Job | Identity | Can |
|---|---|---|
| Every job (build, test, merge requests) | the realm rule’s read-only role | restore from the feeds the role reads |
| The release job | the same role, plus a trusted publisher | restore, and push the named packages to one hosted repository |
Both identities come from the same GitLab ID token and the same Forge realm. A
job holds exactly one role, the one the realm’s first matching rule names,
and an issuer has exactly one enabled realm. The write identity is therefore
no second realm or role: it is a trusted publisher on the hosted
repository, which adds write there for the jobs its conditions match. A job
that matches no publisher keeps the read-only role.
Trusted publishing is the reference behind this guide: the security model, the token exchange and every setting. For pipelines that cannot use workload identity, see CI with service accounts.
The examples use these names; substitute your own:
| Name | Example |
|---|---|
| Forge | https://forge.example.com |
| GitLab, the token issuer | https://gitlab.example.com (or https://gitlab.com) |
| Hosted repository the packages go to | releases |
| Proxy of nuget.org | nuget-org |
Group the builds restore from (releases + nuget-org) | nuget |
| GitLab project ID / namespace ID | 1001 / 42 |
| Packages the project publishes | Acme.Widgets, Acme.Widgets.* |
Before you start
-
GitLab issues ID tokens (the
id_tokenskeyword, GitLab 15.7 and later). Forge must reach the issuer’s/.well-known/openid-configurationand its JWKS over HTTPS and trust the certificate, also for a GitLab on a private network. Plain HTTP is for test instances only. -
You know the issuer and the IDs. The issuer is the exact
issof the instance’s tokens, normally the instance URL ($CI_SERVER_URLin a job). The project ID is on the project’s overview page (⋮ → Copy project ID) and is$CI_PROJECT_IDin a job. The namespace ID is the ID of the group the project sits in directly,$CI_PROJECT_NAMESPACE_IDin a job. -
Which claims your GitLab puts in its tokens. Current GitLab versions carry
job_project_idandjob_namespace_id; older self-managed versions have onlyproject_idandnamespace_id. If in doubt, run this job once and read its log. It prints the token’s claims without its signature, which is no credential; delete the job afterwards, since the claims name the user who ran it.show-forge-claims: image: mcr.microsoft.com/dotnet/sdk:10.0 when: manual id_tokens: FORGE_ID_TOKEN: aud: https://forge.example.com script: - | payload="$(printf '%s' "$FORGE_ID_TOKEN" | cut -d. -f2 | tr '_-' '/+')" while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="$payload="; done printf '%s' "$payload" | base64 -d echo -
The repositories exist in Forge (Admin → Repositories): the hosted NuGet repository the packages go to, and the feeds the builds restore from.
1. The read-only role
Under Admin → Roles, create a role, for example gitlab-ci-read. On its
page, under Grants, add one grant per repository the builds restore from,
each with the level read:
releases, so builds can use the project’s own packages;nuget-org;nuget, the group.
A group serves a caller only the members that caller may read, so grant
read on the group and on each member it should serve from. Leave
All repositories alone unless every job of the project may read every
repository: a role’s grants reach every format, npm, Docker and raw
repositories as well as NuGet.
The role needs no write: the trusted publisher in step 4 adds it for the
release job only.
2. The workload identity realm
Under Admin → Authentication, create a realm of the kind Workload identity:
| Field | Value |
|---|---|
| Name | gitlab (the trusted publisher names the realm) |
| Issuer URL | https://gitlab.example.com, exactly the tokens’ iss |
| Allowed audiences | https://forge.example.com, an audience used for Forge alone |
| Maximum token age (seconds) | at least the longest timeout of a job that uses Forge; the default, 39600 (11 hours), covers most |
| Exchanged API key lifetime (seconds) | how long a job may use its key; the default is 3600 (1 hour) |
Under Rules, add one rule with the role gitlab-ci-read and two
conditions:
| Claim | Any of |
|---|---|
job_project_id | 1001 |
job_namespace_id | 42 |
Every job of project 1001 now matches the rule and gets gitlab-ci-read; a
job of any other project gets 401. The realm’s configuration, as the admin
API takes it (POST /admin/realms):
{
"kind": "workload_identity",
"name": "gitlab",
"config": {
"issuer": "https://gitlab.example.com",
"allowed_audiences": ["https://forge.example.com"],
"max_token_age_seconds": 39600,
"api_key_ttl_seconds": 3600,
"rules": [
{
"role": "gitlab-ci-read",
"conditions": [
{ "claim": "job_project_id", "any_of": ["1001"] },
{ "claim": "job_namespace_id", "any_of": ["42"] }
]
}
]
}
}
Instead of filling in the rule field by field, paste this document into Edit as JSON, at the top of Rules, and apply it. The dialog keeps only the rules of what it is given (the rules alone, a realm’s config, a whole realm, or one rule) and fills in the form with them; saving the realm saves them. It also shows a realm’s rules as JSON, to copy them into another realm or another Forge.
- Numeric IDs, not paths. A project or group can be renamed, and another
can take its old path. On
https://gitlab.comevery rule must contain a project ID and a namespace ID; a self-managed GitLab accepts any stable identity claim, but pin both IDs there too. job_*claims where your GitLab has them. In a merge request pipeline run in the parent project,project_idandnamespace_idname the merge request’s source project,job_project_idandjob_namespace_idthe project the job runs in. Useproject_idandnamespace_idonly on a GitLab without thejob_*claims.- More projects go into the same realm: add their IDs to the rule’s lists, or add a rule per team with a role of its own. Rules are ordered, and the first rule whose conditions all match picks the role. On a self-managed GitLab, a last rule can take every project of the instance, read-only.
- Saving the realm revokes its keys. Any change to the realm revokes every key it has issued, so jobs running at that moment fail and must be retried.
3. Who may release, in GitLab
The write identity trusts what GitLab signs into the token, so GitLab decides who may run the release job. Protect the ref the release runs on, and with GitLab Premium or Ultimate also the environment it deploys to:
- Protected tags (Settings → Repository → Protected tags): protect
v*and choose who may create such tags, for example Maintainers. A job of a pipeline for a protected tag getsref_protected: truein its token. - Protected environment (Settings → CI/CD → Protected environments):
protect
nuget-releaseand choose who may deploy to it. A job that deploys to it getsenvironment: nuget-releaseandenvironment_protected: true. The environment must exist first: the first job that names it creates it, or create it under Operate → Environments.
4. The trusted publisher
Open the hosted repository under Admin → Repositories → releases, and in its Trusted publishers panel choose Add trusted publisher:
| Field | Value |
|---|---|
| Publisher name | widgets-release |
| Realm | gitlab |
| Description | who owns it and what it releases |
| Package includes | Acme.Widgets and Acme.Widgets.*, one glob per line |
| Package excludes | empty |
| Claim conditions | the JSON below |
| Allow unlist and delete | off |
[
{ "claim": "job_project_id", "any_of": ["1001"] },
{ "claim": "job_namespace_id", "any_of": ["42"] },
{ "claim": "ref_type", "any_of": ["tag"] },
{ "claim": "ref_protected", "any_of": ["true"] },
{ "claim": "environment", "any_of": ["nuget-release"] },
{ "claim": "environment_protected", "any_of": ["true"] }
]
On GitLab Free, which has no protected environments, leave out the two
environment conditions; the protected tag then decides alone.
- Pin the packages. An empty include list means every package. Patterns match the package ID without regard to case.
- The publisher only adds. It never authenticates a job by itself: a job
must match the realm rule first, and then holds
gitlab-ci-readpluswriteonreleasesfor the included packages. ref_protectedbelongs next to the environment. It keeps a merge request pipeline from a fork, which a maintainer may run in the parent project, from publishing even when it deploys to the environment: a merge request’s ref is never protected.- Unlist and delete add the
deletelevel for the same packages. Leave them off unless the pipeline removes versions. - Editing or deleting the publisher withdraws its rights from the keys already issued; the realm’s other keys keep working.
The admin API takes the same publisher at
POST /admin/repositories/releases/trusted-publishers:
{
"name": "widgets-release",
"description": "Tag releases of Acme.Widgets from the protected nuget-release environment",
"realm": "gitlab",
"packages": { "includes": ["Acme.Widgets", "Acme.Widgets.*"], "excludes": [] },
"actions": ["write"],
"conditions": [
{ "claim": "job_project_id", "any_of": ["1001"] },
{ "claim": "job_namespace_id", "any_of": ["42"] },
{ "claim": "ref_type", "any_of": ["tag"] },
{ "claim": "ref_protected", "any_of": ["true"] },
{ "claim": "environment", "any_of": ["nuget-release"] },
{ "claim": "environment_protected", "any_of": ["true"] }
]
}
5. The project
The project’s nuget.config names Forge’s group feed and holds no
credentials:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="forge" value="https://forge.example.com/repository/nuget/index.json" />
</packageSources>
</configuration>
In .gitlab-ci.yml, a hidden job requests the ID token and exchanges it for a
key in before_script; every job that uses Forge extends it:
variables:
FORGE_URL: https://forge.example.com
.forge:
image: mcr.microsoft.com/dotnet/sdk:10.0
id_tokens:
FORGE_ID_TOKEN:
aud: https://forge.example.com
before_script:
- |
FORGE_KEY="$(curl --fail --silent --show-error -X POST "$FORGE_URL/api/v2/token" \
-H "Authorization: Bearer $FORGE_ID_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"username":"oidc","tokenType":"ApiKey"}' \
| sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')"
test -n "$FORGE_KEY"
export FORGE_KEY
export NuGetPackageSourceCredentials_forge="Username=oidc;Password=$FORGE_KEY"
build:
extends: .forge
script:
- dotnet restore
- dotnet build -c Release --no-restore
- dotnet test -c Release --no-build
publish:
extends: .forge
stage: deploy
environment:
name: nuget-release
rules:
- if: $CI_COMMIT_TAG
script:
- dotnet restore
- dotnet pack -c Release -o artifacts --no-restore
- >-
dotnet nuget push "artifacts/*.nupkg"
--source "$FORGE_URL/repository/releases/package"
--no-service-endpoint
--api-key "$FORGE_KEY"
-
The audience under
id_tokensmust be one of the realm’s allowed audiences, exactly. -
The exchange runs once per job. A token can be exchanged only once, and the job’s later commands share the key.
FORGE_KEY="$(…)"stays a plain assignment so that a failed exchange fails the job:export FORGE_KEY="$(…)"would succeed whatevercurlreturned, and the job would go on without a key. The .NET SDK image hascurlbut nojq, sosedpicks the key out of the answer,{"apiKey":"forge_wif.…","expires":"…"}. -
A job with a
before_scriptof its own replaces the hidden job’s, and then has no key. List the hidden job’s first:test: extends: .forge before_script: - !reference [.forge, before_script] - dotnet tool restore script: - dotnet test -
NuGetPackageSourceCredentials_forgegives theforgesource ofnuget.configthe key as its password; the part after the_must be the source’s key. NuGet ignores a malformed value without a message. -
The push goes to the hosted repository’s publish URL with
--no-service-endpoint, so NuGet sends a singlePUTwith the key instead of first reading the service index. -
The publish job restores with the read-only role, which every job of the project holds, and pushes with the write the trusted publisher adds.
Every project on a runner
The hidden job above lives in each project. An organization that runs its own
GitLab Runner for its .NET builds can run the exchange on the runner instead,
once for every job it builds. Each job then restores with the realm rule’s
read-only role, and only a job that a trusted publisher matches can push. The
runner’s pre_build_script runs in the job’s shell right before the job’s
before_script and script, so the key it exports reaches the job.
The runner cannot ask for the token itself: GitLab issues an ID token only to
a job that declares id_tokens. Each project keeps that part.
The realm rule
The rule of step 2 names every project the runner builds. On a self-managed GitLab a rule may pin namespaces alone: every project directly in one of the groups matches, and so does a project created there later.
{
"role": "gitlab-ci-read",
"conditions": [
{ "claim": "job_namespace_id", "any_of": ["42", "43", "57"] }
]
}
- A subgroup is a namespace of its own.
job_namespace_idis the group a project sits in directly, so a group’s ID does not cover its subgroups: list each of them. - On
https://gitlab.comevery rule needs project IDs as well: list the projects there. - Keep the role read-only. Every job of those groups holds it, merge request pipelines included.
- Rules are ordered. A project that needs a role of its own goes into a rule before this one.
Every project of the instance
On a self-managed GitLab whose projects all may restore, one rule takes every job the instance signs a token for, a project or subgroup created later included, where a list of groups must be kept current:
{ "role": "gitlab-ci-read", "issuer_wide": true, "conditions": [] }
In the web UI it is the rule’s Every project of this issuer switch.
- Everyone who runs a pipeline gets the role. Every account that can create a project and run a pipeline on the instance reads with it, in a personal namespace or a fork too. A GitLab with open sign-up is as public as GitLab.com: close sign-up, or pin the groups as above.
- Read-only, whatever the role grants. A job this rule matched holds the
role’s grants at
read, and so do its exchanged key and the registry tokens minted from either: awriteordeletegrant on the role does not apply to it, so a role givenwritelater does not hand it to every project. - Projects that publish keep their trusted publisher
(step 4), which adds
writefor the project’s own packages on top, or get a rule of their own ahead of this one, whose role may write. - Last. Rules are first-match, so this rule comes after every other rule; Forge refuses the realm otherwise.
- Conditions narrow it. Add context conditions, such as
ref_protectedtruefor the pipelines of protected branches and tags only; none is required. - Not on
https://gitlab.com(nor for GitHub Actions): anyone can mint its tokens, and Forge refuses the rule there.
The runner
In the runner’s config.toml, in its [[runners]] entry:
[[runners]]
# name, url, token, executor, …
environment = ["FORGE_URL=https://forge.example.com"]
pre_build_script = '''
if [ -n "${FORGE_ID_TOKEN:-}" ] &&
[ -n "$(find "$CI_PROJECT_DIR" -maxdepth 3 -iname nuget.config -exec grep -liF "$FORGE_URL" {} + 2>/dev/null)" ]; then
FORGE_KEY="$(curl --fail --silent --show-error -X POST "$FORGE_URL/api/v2/token" \
-H "Authorization: Bearer $FORGE_ID_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"username":"oidc","tokenType":"ApiKey"}' \
| sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')"
test -n "$FORGE_KEY"
export FORGE_KEY
export NuGetPackageSourceCredentials_forge="Username=oidc;Password=$FORGE_KEY"
fi
'''
- Only jobs with a token.
FORGE_ID_TOKENexists only in a job that asks for it, so the runner leaves every other job alone. - Only projects that use Forge. The exchange runs only when a
nuget.configup to three directories deep names$FORGE_URL, whatever the case of the file’s name. Thefindlists nothing both when there is no such file and when none of them names Forge. - A failed exchange fails the job, as in the hidden job:
test -nstops it beforedotnet restoregets 401. - The image of such a job needs
curl,sed,findand a POSIX shell; the .NET SDK image has them. A Windows runner needs a PowerShell version. - The source key is
forgein every project’snuget.config:NuGetPackageSourceCredentials_forgereaches that source alone. - Your own runners only. GitLab.com’s instance runners take no configuration of yours: register a project, group or instance runner of your own.
The projects
Each project asks for the token in every job, and runs no exchange of its own:
default:
image: mcr.microsoft.com/dotnet/sdk:10.0
id_tokens:
FORGE_ID_TOKEN:
aud: https://forge.example.com
build:
script:
- dotnet restore
- dotnet build -c Release --no-restore
- dotnet test -c Release --no-build
- No
extends: .forge. A token is exchanged once, so the hidden job’s exchange would get 401 after the runner’s. - The release job of step 5 stays as it is, without
extends: .forge: it pushes with the$FORGE_KEYthe runner exported, which the trusted publisher of step 4 giveswrite. - The jobs run on that runner: give them its tags, or let it pick up untagged jobs.
The runner writes the NuGet configuration
The runner above exchanges only for a project whose own nuget.config names
Forge. It can write the configuration itself instead, so that a project needs
none: a job with a token gets Forge’s feed and the key, and every other job a
NuGet.Config of your own, which an instance CI/CD variable, CI_NUGET_CONFIG,
holds base64-encoded.
[[runners]]
# name, url, token, executor, …
environment = ["FORGE_URL=https://forge.example.com"]
pre_build_script = '''
nuget_config="$HOME/.nuget/NuGet/NuGet.Config"
if [ -n "${FORGE_ID_TOKEN:-}" ]; then
FORGE_KEY="$(curl --fail --silent --show-error -X POST "$FORGE_URL/api/v2/token" \
-H "Authorization: Bearer $FORGE_ID_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"username":"oidc","tokenType":"ApiKey"}' \
| sed -n 's/.*"apiKey":"\([^"]*\)".*/\1/p')"
test -n "$FORGE_KEY"
export FORGE_KEY
export NuGetPackageSourceCredentials_forge="Username=oidc;Password=$FORGE_KEY"
mkdir -p "$(dirname "$nuget_config")"
cat > "$nuget_config" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="forge" value="$FORGE_URL/repository/nuget/index.json" />
</packageSources>
</configuration>
EOF
elif [ -n "${CI_NUGET_CONFIG:-}" ]; then
mkdir -p "$(dirname "$nuget_config")"
printf '%s' "$CI_NUGET_CONFIG" | base64 -d > "$nuget_config"
fi
'''
- The user’s configuration. The runner writes
~/.nuget/NuGet/NuGet.Config, the file NuGet reads beneath every project’s own, in place of the one the image brings (the .NET SDK image’s names nuget.org). A project without anuget.configrestores from what the runner wrote. A project’s ownnuget.configstill comes first: one with<clear />drops the runner’s sources, and then names Forge under the keyforgeitself, as in step 5. - A job with a token gets Forge’s group feed under the key
forge. Its key goes inNuGetPackageSourceCredentials_forge, as above, and stays out of the file. The token decides alone: a project that asks for it uses Forge, whether or not it has anuget.config. - A failed exchange fails the job. The job does not fall back to
CI_NUGET_CONFIG: a job that asks for a token is meant to reach Forge with it. - Every other job gets
CI_NUGET_CONFIG, decoded as it is: the jobs of a project that asks for no token. Add the variable under Admin → Settings → CI/CD → Variables, or in a group’s CI/CD variables for that group’s projects alone;base64 -w0 NuGet.Configprints its value. A value that is no base64 fails the job. While the variable is unset, a job without a token keeps the image’s configuration. CI_NUGET_CONFIGis a secret when it holds credentials. An instance variable reaches every job of every project on every runner, not only this one’s, and any of those jobs can print it, as with the runner’s environment. Give a token in it the read level alone and an expiry.- A container per job. With the Docker or Kubernetes executor every job
runs in a fresh container, and the file and the key go with it. A shell
executor shares
$HOMEbetween all its jobs, concurrent ones too: they would overwrite each other’s configuration, and the last one’s would stay. Use this runner only with an executor that gives each job a container. - The image of a job needs
curl,sed,base64and a POSIX shell; the .NET SDK image has them.
Without workload identity
A runner can also hand every job a service account’s read token, with no change to the projects:
[[runners]]
environment = ["NuGetPackageSourceCredentials_forge=Username=gitlab-ci@example.com;Password=<read token>"]
The token is then a long-lived secret in the runner’s configuration, and every job the runner runs can print it, whatever project the job belongs to. Give it the read level alone, an expiry, and only the repositories the builds restore from (step 3 there).
What each identity can do
| Every job | The release job | |
|---|---|---|
Restore from releases, nuget-org and nuget | yes | yes |
Push Acme.Widgets* to releases | no, 403 | yes |
| Push another package, or to another repository | no | no |
| Unlist or delete | no | only with Allow unlist and delete |
| The admin API, the web UI, API tokens | never | never |
- No account. A job is a workload, not a user: it signs in nowhere, and
/auth/tokensneeds a browser session, so it cannot mint an API token. Forge refuses a realm rule that maps to the built-inadminrole, so no token reaches the admin API. - Public reads. Like every caller, a job may also read what anonymous access allows.
- Lifetimes. The key lives for the realm’s exchanged API key lifetime, whatever the job’s timeout. The ID token itself lives until the job’s timeout but can be exchanged only once.
- Revoking. Disabling or editing the realm revokes all its keys for good;
enabling it again does not bring them back. Removing a grant from
gitlab-ci-readtakes effect on the next request. - Audit. Every publish by a job is recorded in
trusted_publish_auditwith the realm, the role, the publisher and digests of the token’s subject andjti.
Troubleshooting
| Symptom | Cause |
|---|---|
| The exchange answers 401 | No rule matched: check the issuer (the tokens’ exact iss), the audience, and the IDs; on an older GitLab use project_id / namespace_id. Or the token is older than the maximum token age, or was exchanged already (a retried curl, a second exchange in the job, the project’s exchange after the runner’s). |
| The exchange answers 503 | Forge cannot fetch GitLab’s discovery document or JWKS: the network between them, a proxy, or a certificate Forge does not trust. |
dotnet restore gets 401 | NuGetPackageSourceCredentials_<key> does not name the nuget.config source, or the key expired during a long job: raise the exchanged API key lifetime. |
| A package from a group member is missing | The role reads the group but not that member. |
| The push gets 403 | The job did not match the publisher (not a protected tag, not the protected environment), or the package ID is outside its includes. |
| The push gets 405 | It went to a proxy, or to a group that names no writable member: push to /repository/releases/package. |
| The push gets 401 | The key was revoked by an edit of the realm, or expired. |
Without the exchange
A client that cannot run the exchange can present the ID token itself on
every request, as the Basic password of the username oidc and as the API
key. Trusted publishing shows
the GitLab job; the exchange is preferred, since the token then cannot be
replayed from a job log.