Azure DevOps Variable Groups and Key Vault: the right way
Linking Azure Key Vault to Azure DevOps Variable Groups is the cleanest way to handle secrets in pipelines. Here's how to set it up properly, and the gotchas to avoid.
There are several ways to handle secrets in Azure Pipelines. Pasting them directly into Variable Groups (the lazy way), reading them from a file (the hacky way), or what you should be doing linking a Variable Group to Azure Key Vault.
The result: secrets live in Key Vault (where they belong), pipelines reference them by name, rotation is invisible to your YAML.
Here's the right way to set it up.
The architecture
[Key Vault]
│ (secret: "stripe-api-key")
│
▼
[Variable Group "shared-prod-secrets"]
│ (linked to vault, syncs the secret as a variable)
│
▼
[Pipeline]
└── variables: { group: shared-prod-secrets }
steps: { uses: $(stripe-api-key) }
When the pipeline runs, the variable is fetched from Key Vault at runtime and made available, masked in logs, scoped to the job.
Step 1: Create the Key Vault and add secrets
az keyvault create \
--name kv-myproject \
--resource-group rg-myproject \
--location uksouth \
--enable-rbac-authorization true
az keyvault secret set \
--vault-name kv-myproject \
--name stripe-api-key \
--value 'sk_live_...'
Use RBAC mode (covered in my Key Vault RBAC post). Don't use Access Policies; they're the legacy model.
Step 2: Create a service connection from Azure DevOps to Azure
In Azure DevOps: Project Settings → Service connections → New → Azure Resource Manager → Workload identity federation (recommended).
Workload identity federation is the OIDC equivalent for Azure DevOps. No client secret stored anywhere, just like the GitHub Actions OIDC pattern.
Step 3: Grant the service connection access to Key Vault
You need two roles on the service connection's underlying service principal:
SP_OBJECT_ID=<from the service connection page>
az role assignment create \
--role "Key Vault Secrets User" \
--assignee $SP_OBJECT_ID \
--scope $(az keyvault show -n kv-myproject --query id -o tsv)
Key Vault Secrets User gives read access, exactly what the variable group needs. Don't grant Secrets Officer as pipelines don't need to write secrets.
Step 4: Create the linked Variable Group
In Azure DevOps: Pipelines → Library → + Variable group:
- Name:
shared-prod-secrets. - Toggle Link secrets from an Azure key vault as variables.
- Pick the service connection from Step 2.
- Pick your Key Vault.
- Click Add and select the secrets you want to expose.
Each selected secret becomes a variable in the group. The variable name matches the secret name.
Step 5: Use it in the pipeline
variables:
- group: shared-prod-secrets
steps:
- script: |
curl -H "Authorization: Bearer $(stripe-api-key)" \
https://api.stripe.com/v1/charges
displayName: Call Stripe
That's it. The secret is fetched at job start, masked in logs (***), and never appears in the pipeline definition.
Per-environment variable groups
A pattern I use:
shared-dev-secretslinked tokv-myproject-dev.shared-prod-secretslinked tokv-myproject-prod.
In your stages:
- stage: deploy_dev
variables: { group: shared-dev-secrets }
...
- stage: deploy_prod
variables: { group: shared-prod-secrets }
...
Environment-specific vaults mean dev secrets and prod secrets never coexist in the same group, so a misconfiguration can't accidentally hand a prod secret to a dev pipeline.
The gotchas
Cache invalidation lag. When you update a secret in Key Vault, the variable group can take a few minutes to pick it up. For most secrets that's fine; for time-critical rotations, plan for it.
Secrets aren't passed to scripts as environment variables by default. They're set as pipeline variables. To use them in a script's environment, map them explicitly:
- script: ./deploy.sh
env:
STRIPE_KEY: $(stripe-api-key)
This is the most common confusion I see. Secrets work in inline scripts but seem to disappear in shell scripts. The env: mapping is the bridge.
Group permissions are per-group, not per-secret. If a service connection has access to the vault, it can read every secret in that group's filter. Use multiple groups and multiple service connections to scope tighter.
Don't put non-secret variables in linked groups. Mixing config and secrets in the same Variable Group creates governance ambiguity. Use a separate, regular Variable Group for non-secret config.
Why this beats the alternatives
- Pasted Variable Groups: secrets sit in Azure DevOps, easy to leak, hard to rotate. Bad.
- Key Vault tasks in pipelines: works but adds boilerplate to every pipeline. Linked Variable Groups centralize the wiring.
- Service connections to Key Vault per pipeline: possible but creates lots of service connections to manage.
The linked Variable Group is the cleanest abstraction Azure DevOps offers for this. Use it.
The summary
If your pipeline references $(stripe-api-key) directly from a non-linked Variable Group, the next thing you build should be: move that secret to Key Vault, link the group, change nothing in your pipeline. Twenty minutes of work, infinitely better security posture.