Blog
Deploying self-hosted GitLab to Azure - secretless, but the wildcard federation does not apply
A deploy secret in your CI/CD variables is convenient and long-lived - and that is exactly the problem. It sits around for months, is rarely rotated, and anyone with read access to the pipeline variables effectively holds the deploy right. Workload Identity Federation (OIDC) puts an end to that: on every run the pipeline receives a short-lived, signed token, exchanges it for an Azure access token, and deploys with that. No stored secret, nothing to rotate.
Three ways to authenticate a deploy
Before diving into OIDC, the overview - this is how a GitLab deploy reaches an Azure App Service instance:
| Method | Upside | Downside |
|---|---|---|
| Basic auth (Kudu publishing credentials) | Simple and available everywhere, with no Entra setup | Considered outdated and avoided by admins. Long-lived password in the CI variables, often disabled by policy entirely. Really just a last resort when nothing else works |
| Service principal (with client secret) | Central identity with fine-grained RBAC across many resources | The client_secret is a long-lived secret - it expires and must be renewed and stored securely |
| OIDC (Workload Identity Federation) | No secret in CI, nothing expires. Short-lived tokens bound to the project, central RBAC | More involved setup (one FIC per repo, sub patch, token exchange). Plus the 20-FIC limit and clunkier debugging |
My pick: OIDC as the default. Service principal with a secret is the middle ground when OIDC does not (yet) work - basic auth truly only as a last resort. The rest of the article shows why OIDC costs three extra steps on self-hosted GitLab.
How OIDC deploy works in principle
Three parts play together:
- GitLab issues an OIDC
id_tokento the pipeline. The token carries claims such asiss(the GitLab instance) andsub(which project/branch). - Azure trusts that token when a Federated Identity Credential (FIC) on a managed identity or app registration exactly matches the token’s
iss,subandaud. - The pipeline exchanges the GitLab token at Entra ID for an Azure access token and uses that as the bearer for the actual deploy (msdeploy, Kudu REST, ARM).
The convenient route on GitLab.com is flexible FICs with wildcards in the subject - one rule covers many branches or projects. That is where the trouble starts.
Why the wildcard federation does not apply on self-hosted
On GitLab.com OIDC is almost automatic. On a self-hosted instance - one whose host is not gitlab.com - I hit two Azure limits during my rollout:
- The issuer allowlist for the flexible FIC requires a
gitlab.*host. My instance’s issuer, however, was its own domain (heregitlab.example.com) - so the wildcard route was out. - Flexible FICs could also only be created on app registrations, not on managed identities. If, like me, you want to deploy secretless via a managed identity, you are left with exact-match FICs: one fixed FIC per project, no placeholders.
In short: no wildcard, no catch-all rule. Instead one FIC per repo - and a token exchange you write yourself. (Flexible FICs are a preview feature and their limits can change - the docs linked below are worth a look for your own tenant configuration.)
The solution in three parts
1. Make the GitLab sub branch-free
By default GitLab builds the sub claim from project_path, ref_type and ref - so it changes with every branch. An exact-match FIC would then break on each new branch. The fix: reduce the sub project-wide to just project_path (via the GitLab Projects API). The token becomes branch-free and survives any branch switch.
# Reduce the sub claim project-wide to project_path (branch-free)
curl --request PUT \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--url "https://gitlab.example.com/api/v4/projects/1234" \
--data "ci_id_token_sub_claim_components[]=project_path"
After that the sub is simply project_path:myorg/myproject.
2. Create one exact-match FIC per project
On the managed identity, each repo gets a FIC with an exactly matching issuer, subject and audience:
az identity federated-credential create \
--name gitlab-myproject \
--identity-name my-deploy-mi \
--resource-group my-rg \
--issuer "https://gitlab.example.com" \
--subject "project_path:myorg/myproject" \
--audiences "api://AzureADTokenExchange"
Important: the subject must match the GitLab sub character for character (case included). A typo does not show up at creation time - only at the token exchange, with AADSTS70021 (no matching federated identity record).
3. Build the token exchange yourself - the “curl patch”
For self-hosted GitLab there is no ready-made Azure login step that does the exchange for you. So the pipeline fetches the id_token and exchanges it by hand:
deploy:
id_tokens:
AZURE_OIDC_TOKEN:
aud: api://AzureADTokenExchange
script:
- ./deploy.sh # does the exchange and deploys
# Exchange the id_token for an Azure ARM token (the actual "curl patch")
RESPONSE=$(curl --silent --request POST \
--url "https://login.microsoftonline.com/$TENANT/oauth2/v2.0/token" \
--data "grant_type=client_credentials" \
--data "client_id=$CLIENT_ID" \
--data "scope=https://management.azure.com/.default" \
--data "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data "client_assertion=$AZURE_OIDC_TOKEN")
ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r .access_token)
# then use ACCESS_TOKEN as "Authorization: Bearer" for msdeploy / Kudu REST / ARM
The core is the client_assertion: instead of a client secret, the pipeline presents the GitLab id_token as proof. Entra ID checks it against the FIC and returns an Azure token. From there it is a normal bearer deploy - msdeploy with authType=Bearer, Kudu REST with Authorization: Bearer, or straight against the ARM API.
Being honest about the edges
The approach works, but it has edges worth knowing up front:
- One FIC per repo, no wildcard. This scales with the number of repos. A managed identity has a limit of 20 FICs - with more projects you have to group them or spread across several identities.
- Create FICs sequentially. Creating several in parallel can make Azure answer with
409 Conflict. - A wrong subject stays invisible for a while. Creation does not fail - only the first real token exchange reveals the typo.
- Keep a fallback. I left the old secret / basic-auth path as a selectable fallback in the pipeline until OIDC was proven across every environment. A deploy path you can no longer switch back to in an emergency is a bad trade.
Microsoft documents the exact Azure restrictions on flexible FICs (supported issuers, app registration vs. managed identity, limits) - worth a look for your own tenant configuration, as the details can change.
Takeaway
Secretless deployment is the right direction, and OIDC / Workload Identity Federation is the clean tool for it. On GitLab.com you get it for free. On a self-hosted instance it costs three extra steps: patch the sub to be branch-free, create one exact-match FIC per repo, and write the token exchange yourself. After that there is no deploy secret left in the CI variables - and that is worth the effort.
Sources: GitLab: OIDC with ID tokens · GitLab: Configure OpenID Connect in Azure · Microsoft: Workload Identity Federation · Microsoft: Considerations (20 FIC limit) · Microsoft: Flexible federated identity credentials