By Jan van de Pol – Cloud / Principal Lead Cloud & DevOps at HighTech Innovators
Cloud Platform Team

At HighTech Innovators we run a lot of Azure. Not just our own tenant, but the tenants of the clients whose cloud we manage. A colleague of mine already wrote here about how we use Pulumi to take full control of our Azure infrastructure as code. This post is about the piece that sits underneath all of that: how a pipeline is allowed to talk to Azure in the first place, and how we got rid of the stored secret that used to make it work.

If you deploy infrastructure from a CI pipeline, you have run into this question. Your pipeline needs to authenticate to Azure to create and update resources. The classic answer is to register an app in Entra, give it a client secret, and paste that secret into your CI variables. It works. It is also the thing I least liked having in our setup.

The problem with a secret in the pipeline

A client secret is a long-lived credential with real power. Once it is stored in CI, it is a full-access key that stays there until someone remembers to rotate it. For a single project that is uncomfortable. For a managed service provider that touches many tenants, it is the kind of thing that keeps you awake. People (all of us) can fall for a phishing mail or leak a variable by accident, and a leaked secret does not expire on its own. On top of that, every secret is one more thing to rotate on a schedule, and rotation is exactly the sort of chore that quietly slips.

BEFORE A stored client secret GitLab CI pipeline job AZURE_CLIENT_SECRET long-lived · full access · sits in CI storage Azure Leaks once, and it grants access until someone remembers to rotate it. AFTER OIDC federation GitLab CI pipeline job id_token (signed JWT) minted per job · expires in minutes · stored nowhere Azure A fresh token every run. Nothing to store, nothing to rotate, nothing to leak.
The same job, two ways to authenticate. The stored secret on the left is the part we wanted gone.

What I wanted was a pipeline that could prove who it is at the moment it runs, without carrying a permanent key around. That is exactly what OpenID Connect (OIDC) workload-identity federation gives you.

What “no secrets” really means

Instead of storing an Azure secret, we register a federated credential on the Entra app registration. In plain terms, that credential is a trust rule: trust identity tokens issued by our GitLab, but only for this project, on this branch, and nothing else.

At runtime GitLab mints a fresh, signed token for the job. The job hands that token to Azure, Azure checks the signature against GitLab’s public keys and checks the token against the trust rule, and if everything lines up it returns a short-lived Azure access token. That token is what Pulumi uses to do its work. A minute or two later it has expired and is gone.

GitLab CI job GitLab OIDC issuer Azure AD Entra Azure ARM request id_token (aud = agreed audience) 1 signed JWT (sub = project_path : ref : branch) 2 token exchange: client_id + the JWT as assertion 3 fetch /.well-known/openid-configuration + JWKS 4 discovery doc + public signing keys 5 short-lived Azure access token (if sub / aud / issuer match) 6 pulumi preview / up using the Azure token 7 No secret leaves Azure and none is stored in GitLab. The only thing that travels is a freshly signed, single-use token that Azure verifies against a federated credential.

The handshake, end to end. A signed token goes out, a short-lived access token comes back, and nothing is ever stored on either side.

The best part is what is missing from that picture. There is no client secret in GitLab. There is no key sitting in a variable waiting to leak. The token that travels is single-use and short-lived, and it only works from the exact pipeline it was issued to.

Three things that have to line up

The trust rule is strict on purpose, and that strictness is also where people trip up the first time. Three claims in the GitLab token have to match the federated credential exactly: the issuer, the subject, and the audience.

WHAT GITLAB PUTS IN THE TOKEN THE FEDERATED CREDENTIAL FIELD ISSUER the GitLab instance URL gitlab.contoso.com = issuer must equal the GitLab URL SUBJECT project_path:group/project :ref_type:branch:ref:main = subject exact string, no wildcards AUDIENCE the aud of the id_token set in .gitlab-ci.yml = audiences[ ] one entry, matching aud

Get one of these wrong and Azure answers with AADSTS70021. Nine times out of ten it is the subject.

The subject is the one worth staring at. It is built from the GitLab namespace path, the part in your clone URL, which is not always the same as the folder name on disk. And Azure federated credentials do not support wildcards, so every branch you deploy from needs its own credential. It sounds fussy, but that is the whole point: a token minted for main on one project cannot be replayed anywhere else.

Setting it up is a couple of Azure CLI calls. Create the app and service principal, then register the credential that pins the exact subject:

# the trust rule: this GitLab, this project, this branch
cat > fic.json <<JSON
{
  "name": "gitlab-main-branch",
  "issuer": "https://gitlab.contoso.com",
  "subject": "project_path:group/project:ref_type:branch:ref:main",
  "audiences": ["https://gitlab.contoso.com"]
}
JSON
az ad app federated-credential create --id "$APP_ID" --parameters @fic.json

On the pipeline side, the job asks GitLab for the token and tells the Azure provider to use it. No secret is exported, only the OIDC token:

id_tokens:
  AZURE_OIDC_TOKEN:
    aud: "https://gitlab.contoso.com"  # must equal the credential's audience
before_script:
  - export ARM_CLIENT_ID="$AZURE_CLIENT_ID"
  - export ARM_TENANT_ID="$AZURE_TENANT_ID"
  - export ARM_OIDC_TOKEN="$AZURE_OIDC_TOKEN"
  - export ARM_USE_OIDC=true

Then give the service principal only the RBAC it needs, scoped to the target subscription or resource group rather than the whole tenant. Least privilege, as always.

Keeping the state secure too

There is one more secret that quietly creeps into a lot of Pulumi setups: the storage account key for the state backend. We keep Pulumi state in Azure Blob, and it would be easy to hand the pipeline an account key to reach it. So we do not. We create the storage account with shared-key access disabled, so the two long-lived account keys simply do not work, and the pipeline reaches state through the same federated identity it already has.

az storage account create -n "$ACCOUNT" -g "$RG" -l westeurope \
  --sku Standard_LRS --kind StorageV2 --min-tls-version TLS1_2 \
  --allow-blob-public-access false --allow-shared-key-access false

The built-in Azure policy that flags shared-key access stays green, and there is one fewer secret in the world. The runner gets Storage Blob Data Contributor on the account and reaches state with its own identity.

That is how the pipeline reaches the state. There is a second, easy-to-miss secret hiding one level deeper: the key that encrypts the secret values inside the state file. Pulumi encrypts secret config and secret outputs before they are written to blob, and on a self-managed backend it defaults to a passphrase, which means one PULUMI_CONFIG_PASSPHRASE variable in CI. It works, but it is the single secret still standing after OIDC has removed all the others, which sits a little awkwardly in a setup you set out to make secretless.

You can close that last gap with an Azure Key Vault secrets provider. The encryption key then lives in the vault, the runner unlocks it with the same federated identity it already uses, and the passphrase leaves your pipeline for good:

pulumi stack init dev \
  --secrets-provider "azurekeyvault://contoso-kv.vault.azure.net/keys/pulumi"
# then grant the runner the "Key Vault Crypto User" role on that key

This is the setup we now treat as the default. A strong passphrase in a protected, masked variable is still a perfectly correct fallback, but the vault is what gets you all the way to a pipeline with nothing worth stealing in it, so that is where we start.

The one gotcha nobody warns you about

A bit of honesty from experience: the first time I wired this up, every single job failed with AADSTS90061 and I lost an hour to it. The cause is easy to miss. To validate a GitLab token, Azure has to fetch GitLab’s OIDC discovery document from the public internet. On a self-hosted GitLab, the proxy in front of it often swallows the whole /.well-known/ path (usually a Let’s Encrypt rule), and the discovery URL quietly returns a 404.

One-time, whole instance

This applies once to the GitLab instance, not per project, but it is mandatory. Without a reachable discovery endpoint, every project’s OIDC login fails. The fix is an exact-match nginx location that out-prioritises the ACME prefix, so certificate renewals keep working and only the discovery URL is proxied to GitLab.

One curl tells you whether you are good before you debug anything else:

curl -s -o /dev/null -w "%{http_code}\n" \
  https://gitlab.contoso.com/.well-known/openid-configuration
# expect 200

The comfort of a pipeline with nothing to steal

Once it is running, the payoff is quiet, which is the best kind. There is no secret in the pipeline to rotate or leak. Access is scoped to one project and one branch, so a token cannot wander. And because the pattern is the same for every project, we can roll it out across every tenant we manage, from our own to each client’s, without inventing a new mechanism each time. For an MSP that is a real relief: security that scales by repetition instead of by exception.

It also changes how the team works, in the same spirit my colleague described for Pulumi itself. Deploying to Azure no longer depends on one person guarding a secret. The trust rule lives in code and in Entra, the pipeline does the deploy, and colleagues can contribute through a pull request without ever touching a credential.

I can recommend OIDC workload-identity federation to anyone running infrastructure pipelines against Azure, whether you use Pulumi, Terraform’s open successors, or plain Bicep. The setup takes an afternoon, most of which is reading the error codes once so you never have to again. What you get back is a pipeline with nothing worth stealing in it. For us, that is exactly the point.

Samen bouwen aan duurzame digitale vooruitgang?