Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

CI with service accounts

Some pipelines cannot use workload identity: a GitLab older than 15.7, a GitLab whose discovery document and JWKS Forge cannot reach, a CI system without OIDC tokens. They authenticate with an API token of a service account instead: an account for a machine that nobody signs in to.

Prefer workload identity wherever it works. A token stored in GitLab is a long-lived secret: any job that sees it can use it, until it expires or is revoked, from anywhere. This guide keeps the same split as the workload identity guide, so that a leaked token reveals as little as possible:

TokenAccountStored in GitLabCan
Read tokengitlab-ci@example.commasked variable, every pipelinerestore from the feeds the account reads
Release tokengitlab-release@example.commasked and protected variable, protected refs onlypush the named packages to one hosted repository

The examples use the names of the workload identity guide: Forge at https://forge.example.com, the hosted repository releases, the proxy nuget-org, the group nuget, and the packages Acme.Widgets and Acme.Widgets.*.

1. The roles

Reading. The read-only role of the workload identity guide serves here too: gitlab-ci-read, with read on releases, nuget-org and nuget (see step 1 there). One role for both methods keeps what CI may read in one place; give each method its own role when they should differ.

Publishing. Pin the packages with a content selector, then grant write through it:

  1. Under Admin → Content selectors, create acme-widgets with the includes Acme.Widgets and Acme.Widgets.*.
  2. Under Admin → Roles, create widgets-publish, and on its page add the grant: repository releases, level write, content selector acme-widgets.

Without the selector, the grant reaches every package of releases.

2. The service accounts

Under Admin → Service accounts, create two accounts:

NameDescriptionAccessRoles (on its page)
gitlab-ci@example.comrestores for the GitLab project acme/widgets; who owns itReadergitlab-ci-read
gitlab-release@example.comreleases Acme.Widgets from acme/widgets; who owns itReaderwidgets-publish

Reader gives an account no built-in role: it may do what its roles allow. Writer would let it publish anything to every repository; leave it for accounts that really need it. A service account never holds the admin role.

Two accounts rather than one keep the release rights out of the read token, and the logs tell restores and releases apart.

3. The tokens

On each account’s page, under Tokens, create a token:

Read tokenRelease token
Token namegitlab acme/widgetsgitlab acme/widgets release
Token expiry90 days, or 1 year90 days, or 1 year
Levelat most readat most write
Repositoriesevery repository the owner can reachonly releases

The secret is shown once: put it into GitLab straight away. A token’s scope only narrows its account’s roles. Here it keeps the tokens to their purpose even when someone later gives the account a wider role.

4. The GitLab variables

In the project, under Settings → CI/CD → Variables, add:

KeyValueSettings
FORGE_READ_TOKENthe read tokenMasked, not protected
FORGE_RELEASE_TOKENthe release tokenMasked and Protected

A protected variable reaches only pipelines of protected branches and tags. Protect the release tags (Settings → Repository → Protected tags, for example v*, created by Maintainers only), so the release token reaches the pipeline of such a tag and nothing else. The read token is in every pipeline, including those of branches anybody with the Developer role can push: anyone who can change .gitlab-ci.yml can print it. It carries read alone for that reason.

5. The pipeline

nuget.config is the one of the workload identity guide, naming the source forge without credentials. The jobs pass the read token as the source’s password and the release token as the push’s API key:

variables:
  FORGE_URL: https://forge.example.com

.forge:
  image: mcr.microsoft.com/dotnet/sdk:10.0
  before_script:
    - export NuGetPackageSourceCredentials_forge="Username=gitlab-ci@example.com;Password=$FORGE_READ_TOKEN"

build:
  extends: .forge
  script:
    - dotnet restore
    - dotnet build -c Release --no-restore
    - dotnet test -c Release --no-build

publish:
  extends: .forge
  stage: deploy
  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_RELEASE_TOKEN"
  • The username in the source credentials is the account’s name. Forge identifies the account by the token, but the name oidc is reserved for workload identity and refuses every API token.
  • The push goes to the hosted repository’s publish URL with --no-service-endpoint: NuGet then sends a single PUT with the API key, without reading the service index first.

What each token can do

  • Its account’s roles, narrowed by its scope. The read token restores from releases, nuget-org and nuget, and pushes nothing (403). The release token pushes Acme.Widgets* to releases, and nothing else.
  • Never more than an API token. Nobody signs in to a service account, it has no password, and its tokens reach no admin API.
  • Until it ends. A token works until its expiry, until it is revoked on the account’s page, or while the account is deactivated (Deactivate… on its page stops all its tokens at once). The list of service accounts shows when each was last used.

Rotating a token: create a new one on the account’s page, replace the variable’s value in GitLab, run a pipeline, then revoke the old token. With an expiry on every token, a forgotten one ends by itself.

Using both

Workload identity and service accounts work side by side, without any setting: Forge tells the credentials apart.

  • HTTP Basic with the username oidc is only ever a workload credential.
  • A JWT whose issuer has a workload identity realm goes to that realm, and a failure there is final: it never falls through to the password checks.
  • Anything else, such as a service account’s token, is checked as an API token.

So each project can use whichever method it can, and switch when it can. To move a project from a service account to workload identity:

  1. Add its project and namespace IDs to the realm rule, and a trusted publisher for its packages (GitLab CI with workload identity, steps 2 to 4).
  2. Change its pipeline to the token exchange (step 5 there), and run it.
  3. Revoke the tokens, delete the GitLab variables, and deactivate the service accounts once no project uses them.
Workload identityService account
Secret stored in GitLabnonethe tokens, in CI/CD variables
Identityeach job, from claims GitLab signswhoever holds the token
Rightsone role, plus trusted publishersthe account’s roles, narrowed by the token’s scope
Limited to protected refs or environmentsby the token’s claims, which Forge checksby GitLab’s protected variables
Credential lifetimethe exchanged key’s, one hour by defaultuntil expiry or revocation
Auditthe publish audit, with the job’s subject and jti digeststhe account and token in the logs
NeedsForge reaches GitLab’s discovery document and JWKSnothing