A client secret for an Azure service principal stored as a GitLab CI/CD variable is a long-lived credential that every maintainer, every job log and every compromised runner can expose. Workload identity federation removes it: the GitLab job receives a short-lived ID token signed by GitLab, and Microsoft Entra ID exchanges it for an Azure access token only if the token's issuer, subject and audience match a federated identity credential you defined. This page walks through the setup with the Azure CLI, how to prove it works, how to roll back, and how to tighten the trust once it runs. It is for platform and DevOps engineers on GitLab.com, GitLab Self-Managed or GitLab Dedicated who deploy to Azure. For the concepts behind the exchange see the Workload Identity Federation Guide.
Key takeaways
- Three values must match exactly. Entra compares the federated credential's issuer, subject and audience case-sensitively with the
iss,subandaudclaims of the GitLab ID token, and wildcards are not supported in a standard federated credential. - A wrong subject fails silently. Microsoft documents that a credential with the wrong subject is created without error and only fails at token exchange, so test every new credential in a pilot job.
- Use ID tokens, not the old JWT variables.
CI_JOB_JWT_V2was removed in GitLab 17.0; declare an ID token with theid_tokenskeyword and pass it toaz login --federated-token. - Bind trust to IDs, not paths. The default subject uses the project path. GitLab lets you switch the subject to start with
project_id, which survives renames and moves and avoids inheriting trust through a recycled path. - Scope the role, not just the token. Federation removes the secret but not the blast radius. Grant the service principal a narrow role at resource group or resource scope, never Owner on a subscription.
When you need this
- You have
AZURE_CLIENT_SECRETor a certificate in CI/CD variables. Any job that can read the variable can copy it, and the secret works from anywhere until it expires. Replacing it with federation is the main reason to follow this page. - Secret rotation is causing outages. Client secrets expire; federated credentials have no secret to expire, and the ID token itself expires with the job's timeout, or after 5 minutes if no timeout is set.
- You need separate access per branch or environment. One federated credential per subject lets production deployments use a different identity from feature branch jobs.
- Your secrets have leaked before. Tokens and connection strings stored alongside code are a recurring source of incidents; in the Red Hat Consulting GitLab breach, attackers who copied a GitLab instance claimed it contained customer tokens and database connection strings.
Do not use this pattern if your GitLab Self-Managed instance cannot be reached from the internet. Entra must fetch /.well-known/openid-configuration and /oauth/discovery/keys from the issuer to validate the token signature.
Before you start
| Requirement | Detail |
|---|---|
| Azure permissions | Rights to assign roles at the target scope (GitLab's tutorial assumes Owner on the subscription; a narrower role assignment right at resource group scope is enough for the assignment step) |
| Entra permissions | To create the app, at least Application Developer. To add or change federated credentials: app owner, or Application Administrator, Cloud Application Administrator, Global Administrator or Hybrid Identity Administrator |
| Tools | Azure CLI locally or Azure Cloud Shell; a GitLab project where you can edit .gitlab-ci.yml |
| Issuer | https://gitlab.com for GitLab.com, or your instance URL (for example https://gitlab.example.com), publicly reachable over HTTPS |
| Identity choice | An app registration with service principal (used below), or a user-assigned managed identity with az identity federated-credential create |
| Decisions to make first | Which branch, tag or environment may deploy; which Azure scope and role; one audience value (Microsoft recommends api://AzureADTokenExchange) |
| Option | Choose it when | Trade-off |
|---|---|---|
| App registration and service principal | You need Microsoft Graph or multi-tenant access, or the identity is managed by an Entra team | Lives in Entra; creating federated credentials needs app ownership or an application administrator role |
| User-assigned managed identity | The pipeline only touches Azure resources and you want the identity managed with the resource group in Azure Resource Manager | Sign-ins appear in the managed identity sign-in log; federated credentials on one identity must be created one at a time, as concurrent writes return 409 |
Limits worth knowing before design: a maximum of 20 federated identity credentials per application or user-assigned managed identity, the issuer and subject pair must be unique on the app, and the credential name (3 to 120 characters) cannot be changed after creation.
Step-by-step
- Create the app registration and service principal.
Record the application (client) ID and your tenant ID. Name the app after the pipeline and environment so the sign-in logs are readable.appId=$(az ad app create --display-name gitlab-oidc-deploy-prod --query appId -otsv) az ad sp create --id $appId --query appId -otsv - Decide the subject. By default GitLab issues
subasproject_path:{group}/{project}:ref_type:{type}:ref:{branch_name}, for exampleproject_path:platform/payments-api:ref_type:branch:ref:main. To bind trust to the immutable project ID, set the project'sci_id_token_sub_claim_componentsto["project_id", "ref_type", "ref"]through the projects API (update a project); the subject becomesproject_id:{id}:ref_type:{type}:ref:{branch_name}. - Create the federated identity credential. Save as
credential.json:
Then run:{ "name": "gitlab-payments-api-main", "issuer": "https://gitlab.com", "subject": "project_path:platform/payments-api:ref_type:branch:ref:main", "description": "GitLab CI deploy from main", "audiences": ["api://AzureADTokenExchange"] }
Use your own instance URL as the issuer on Self-Managed or Dedicated, with no trailing whitespace: Entra blocks the exchange if the issuer claim has leading or trailing whitespace.az ad app federated-credential create --id $appId --parameters credential.json - Grant a narrow Azure role.
GitLab's tutorial uses Reader on the whole subscription only to demonstrate; scope production deployments to the resource group or resource they change.az role assignment create --assignee $appId --role "<least-privileged role>" \ --scope /subscriptions/<sub-id>/resourceGroups/<rg-name> - Request the ID token in the job and log in.
The client ID and tenant ID are identifiers, not secrets. Thedeploy-prod: image: mcr.microsoft.com/azure-cli:latest variables: AZURE_CLIENT_ID: "<client-id>" AZURE_TENANT_ID: "<tenant-id>" id_tokens: GITLAB_OIDC_TOKEN: aud: api://AzureADTokenExchange script: - az login --service-principal -u $AZURE_CLIENT_ID -t $AZURE_TENANT_ID --federated-token $GITLAB_OIDC_TOKEN - az account show rules: - if: $CI_COMMIT_BRANCH == "main"audinid_tokensmust equal the credential's audience. - Allow for propagation. Microsoft notes that a token request made several minutes after creating a credential may still fail with
AADSTS70021while caches update. Wait and rerun before changing anything.
Several branches or tags: a standard credential matches one exact subject. For "any branch" or "any tag" GitLab documents flexible federated identity credentials, which use a claimsMatchingExpression such as claims['sub'] matches 'project_path:<group>/<project>:ref_type:branch:ref:*'. Microsoft lists this feature as preview and, for GitLab with mutable (path-based) subjects, requires the expression to match both sub and project_id. Prefer protected environments and exact subjects for production.
Verify it worked
- Job output:
az account showprints the expected tenant and subscription, and a read of the target resource group succeeds while a read outside the assigned scope fails. - Entra sign-in logs: the sign-in appears in the service principal sign-in log for the app (user-assigned managed identities appear in the managed identity sign-in log instead). Check the Client credential type field to confirm it was a federated credential, not a secret.
- Negative test: run the same job from a feature branch. It must fail with
AADSTS70021, proving the subject restriction works. - Secret removal: list remaining secrets and certificates on the app with
az ad app credential list --id $appId; the target state is none.
Rollback and common errors
| Error | Likely cause | Fix |
|---|---|---|
AADSTS70021: No matching federated identity record found for presented assertion. | Issuer, subject or audience differs from the token, or the credential has not propagated | Wait a few minutes; then compare values case-sensitively, including group and project path from the project URL |
AADSTS700212 no matching record for presented assertion audience | The job's id_tokens aud differs from the credential's audience | Use the same single audience in both places |
AADSTS501661: Request to External OIDC endpoint failed. | Entra cannot reach the GitLab discovery or keys endpoint | Expose /.well-known/openid-configuration and /oauth/discovery/keys; GitLab suggests clearing the Redis cache after firewall changes |
ID token issuance is disabled | The project path in sub was previously used by another project | Switch ci_id_token_sub_claim_components to start with project_id and update the credential subject |
GitLab 401: unauthorized from the target service | Old CI_JOB_JWT_V2 used, or no id_tokens block | Declare id_tokens in the job |
Rollback: keep the old client secret valid but unused during a pilot window. If federation fails in production, revert the job to the secret-based login in one merge request, then fix the credential. Once the federated job has run cleanly through a full release cycle, delete the secret (az ad app credential delete --id $appId --key-id <key-id>) and the CI/CD variable. To remove federation, delete the credential with az ad app federated-credential delete --id $appId --federated-credential-id <id>; access stops for new exchanges, while access tokens already issued remain valid until they expire.
Hardening afterwards
- Use protected branches and environments. GitLab notes that merge request pipelines from a different project have
ref_protected:false; if you usejob_project_pathorjob_project_idsubjects, addref_protectedto the subject components and requiretrue. Pair production credentials with a protected environment so only approved deployments run. - One identity per environment. Separate app registrations for production and non-production keep a compromised test pipeline from reaching production. Remember the 20-credential limit when planning.
- Guard who can add credentials. Anyone who can add a federated credential to the app can trust a new issuer and subject, which is a persistence route (ATT&CK T1098.001 Additional Cloud Credentials). Keep app owners minimal and review the Application Administrator and Cloud Application Administrator role holders. For user-assigned managed identities, Microsoft shows an Azure Policy deny rule on
Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentialsto stop unapproved creation. - Harden runners. The ID token is a bearer credential for its lifetime. Set short job timeouts, since the token's expiry follows the job timeout, and prefer ephemeral runners for production deployments.
- Alert on change. Monitor Entra audit logs for federated credential changes on deployment apps and service principal sign-ins from unexpected IP ranges. See the GitLab Tokens Security Guide for the other GitLab credentials worth retiring.
| Metric | Target |
|---|---|
| Deployment app registrations with client secrets or certificates | Zero after migration |
| Federated credentials using path-based subjects for production | Falling towards zero as projects move to project_id |
| Federated credential changes without a change ticket | Zero |
| Service principals with subscription-wide Owner or Contributor used by CI | Zero |
| Framework | Reference | Relevance |
|---|---|---|
| MITRE ATT&CK | T1552.001 Credentials In Files; T1078.004 Cloud Accounts; T1098.001 Additional Cloud Credentials | Secrets in CI files and variables; use of the service principal; rogue federated credentials as persistence |
| NIST SP 800-53 Rev. 5 | IA-5, AC-2, AC-6, AU-6 | Authenticator management, account management, least privilege, log review |
| NIST CSF 2.0 | PR.AA-01, PR.AA-05, DE.CM-03 | Credential management, least privilege, monitoring |
Practitioner checklist
- Inventory every Azure client secret and certificate held in GitLab CI/CD variables.
- Create one app registration or managed identity per environment, with a least-privileged role at resource group scope.
- Switch production projects to
project_id-based subjects and create exact-match federated credentials. - Use one audience value,
api://AzureADTokenExchange, in bothid_tokensand the credential. - Run a positive test from the allowed branch and a negative test from a feature branch; confirm the credential type in the sign-in log.
- Delete the old client secret and CI/CD variable after a clean release cycle.
- Restrict who can add federated credentials and alert on any change.
- Set short job timeouts and protected environments for deployment jobs.
Standards and references
- GitLab Docs: Configure OpenID Connect in Azure to retrieve temporary credentials
- GitLab Docs: Connect to cloud services
- GitLab Docs: OpenID Connect (OIDC) authentication using ID tokens
- Microsoft Learn: Workload identity federation
- Microsoft Learn: Configure an app to trust an external identity provider
- Microsoft Learn: Important considerations and restrictions for federated identity credentials
- Microsoft Learn: Flexible federated identity credentials (preview)
- Microsoft Learn: Service principal sign-in logs
Related NHI Mgmt Group resources: Workload Identity Federation Guide · Multi-Cloud Workload Identity Troubleshooting Guide · GitLab Security Guide · How to set up workload identity federation for GitHub Actions to AWS