Document status
- Last reviewed: 2026-05-19
- Authorship: Drafted with AI assistance (GitHub Copilot, multi-model review) and reviewed by a human maintainer before publication.
- Sources: Based on public documentation — primarily docs.github.com, learn.microsoft.com, and official vendor blogs cited inline.
- Verify before acting: GitHub and Microsoft update product documentation continuously. Re-confirm against the live source pages before relying on this content for production decisions.
Microsoft is actively steering customers away from Personal Access Tokens (PATs) toward Microsoft Entra–based authentication. As of April 2025, no new Azure DevOps OAuth app registrations are accepted, and the legacy OAuth platform is targeted for retirement in 2026 (exact end-of-life date will be announced by Microsoft; see the official ADO blog for the latest status). For CI/CD pipelines calling Azure DevOps REST APIs, there are now five viable alternatives to PATs, each with distinct trade-offs in security, cost, and complexity.
For the specific Advanced Security API gating scenario, the recommended path forward is a Service Principal with an Azure DevOps Service Connection using Workload Identity Federation — or the new native Status Checks feature (Sprint 271+) which eliminates the need for API calls entirely.
- Authentication Options Overview
- Option 1: System.AccessToken (Pipeline Job Token)
- Option 2: Service Principal with Entra Token
- Option 3: Managed Identity with Entra Token
- Option 4: Azure DevOps Service Connection (Workload Identity Federation)
- Option 5: Azure CLI Ad-Hoc Entra Tokens
- Advanced Security API — Special Considerations
- Licensing Impact
- Deprecation Timeline
- Recommendation Matrix
- Implementation Examples
- Scaling to 200 Projects with a Custom Pipeline Task
- Pipeline Decorators — The Hardest Scenario
- Why Microsoft Removed Build Identity Access — Security Analysis
- References
| Method | Token Lifetime | Secret Management | Cross-Org | License Cost | Best For |
|---|---|---|---|---|---|
| System.AccessToken | Pipeline job duration | None (automatic) | ❌ No | Free (built-in) | Simple in-org API calls |
| Service Principal | 1 hour (Entra) | Certificate or client secret | ✅ Yes | Basic license per org | Automation, cross-org, Advanced Security |
| Managed Identity | ~1 hour (Azure Identity SDK auto-renews) | None (Azure-managed) | Basic license per org | Azure-hosted agents/apps | |
| ADO Service Connection (WIF) | 1 hour (federated) | None (zero-secret) | ✅ Yes | Basic license per org | Modern pipeline REST API calls |
| Azure CLI Entra Token | 1 hour | Depends on login method | ✅ Yes | Per identity type | Ad-hoc / scripted calls |
| Up to 1 year | Manual rotation | ✅ Yes | Free |
Every Azure Pipelines job automatically gets an OAuth token via $(System.AccessToken). This token authenticates as the Build Service identity (Project Collection Build Service or {Project} Build Service).
- Zero setup — available by default in every pipeline
- Short-lived — expires when the job ends
- No secrets to manage — injected by the pipeline runtime
- Free — no additional license cost
- Broad permissions — all pipelines in the project share the same Build Service identity permissions
- No cross-org support — only works within the current organization
- Build identity restrictions — some APIs (including Advanced Security as of Sprint 269) are restricting access for build identities for security reasons
- No fine-grained scoping — can't limit per-pipeline
As of Sprint 269, build service identities can no longer call Advanced Security APIs. A temporary rollback is in effect until April 15, 2026, after which System.AccessToken will not work for Advanced Security REST API calls.
- Script steps (PowerShell, Bash inline): require explicit
env: SYSTEM_ACCESSTOKEN: $(System.AccessToken)mapping or the "Allow scripts to access the OAuth token" job setting enabled - Pipeline tasks/extensions: can access the token via the Task SDK (
SYSTEMVSSCONNECTIONendpoint) without any user opt-in — see Section 14 for security implications
steps:
- powershell: |
$headers = @{
Authorization = "Bearer $(System.AccessToken)"
"Content-Type" = "application/json"
}
$result = Invoke-RestMethod -Uri "$(System.CollectionUri)$(System.TeamProject)/_apis/projects?api-version=7.2" `
-Headers $headers
$result | ConvertTo-Json
displayName: 'Call ADO REST API with System.AccessToken'
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)Register an application in Microsoft Entra ID, create a service principal, add it to your Azure DevOps organization as a user, then acquire tokens via the client credentials flow (grant_type=client_credentials).
- Short-lived tokens (1 hour)
- Fine-grained permissions — assign specific ADO permissions to the SP
- Cross-org support — works across organizations in the same Entra tenant
- Conditional Access support
- Comprehensive audit trail
- Recommended by Microsoft for Advanced Security API automation
- Requires a Basic license per organization (≈$6/user/month)
- Multi-org billing discount does NOT apply to service principals
- Secret/certificate management — client secrets need rotation; certificates are preferred
- Setup complexity — Entra app registration + ADO user provisioning + permission assignment
POST https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
client_id={client-id}
&scope=https://app.vssps.visualstudio.com/.default
&client_secret={client-secret}
&grant_type=client_credentials
steps:
- powershell: |
$body = @{
client_id = "$(SP_CLIENT_ID)"
scope = "https://app.vssps.visualstudio.com/.default"
client_secret = "$(SP_CLIENT_SECRET)"
grant_type = "client_credentials"
}
$tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$(TENANT_ID)/oauth2/v2.0/token" `
-Method POST -Body $body
$headers = @{
Authorization = "Bearer $($tokenResponse.access_token)"
"Content-Type" = "application/json"
}
# Call Advanced Security API
$alerts = Invoke-RestMethod -Uri "https://advsec.dev.azure.com/$(ORG)/$(PROJECT)/_apis/alert/repositories/$(REPO_ID)/alerts?api-version=7.2-preview.1" `
-Headers $headers
Write-Host "Found $($alerts.count) alerts"
displayName: 'Call ADO API with Service Principal'For Azure-hosted workloads (self-hosted agents on Azure VMs, Azure Functions, etc.), use a system-assigned or user-assigned managed identity. Azure handles all credential rotation automatically.
- Zero secret management — Azure rotates credentials automatically
- Simplest code —
ManagedIdentityCredentialjust works - Same security benefits as service principals (~1-hour Entra tokens; the Azure Identity SDK transparently renews them so long-running apps don't need to re-authenticate. The actual token lifetime remains ~1 hour; conditional access still applies)
- Ideal for self-hosted agents on Azure VMs
- Azure-hosted only — doesn't work from on-premises or non-Azure environments
- Same-tenant only — cannot directly access ADO orgs connected to a different Entra tenant (workaround exists via cross-tenant certificate)
- Requires Basic license per org
- Not available on Microsoft-hosted agents (you don't control the VM identity)
var credential = new ManagedIdentityCredential();
var token = await credential.GetTokenAsync(
new TokenRequestContext(new[] { "https://app.vssps.visualstudio.com/.default" }));
string accessToken = token.Token;az login --identity
TOKEN=$(az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv)
curl -H "Authorization: Bearer $TOKEN" \
"https://dev.azure.com/{org}/_apis/projects?api-version=7.2"A new "Azure DevOps" service connection type uses Entra workload identity federation — a zero-secret method. The pipeline exchanges a short-lived Azure DevOps–issued token for an Entra token via federated credentials, without any stored secrets.
- Zero secrets — no client secrets, no certificates, no PATs
- Per-pipeline permissions — unlike
System.AccessToken, scoped to individual pipelines - Cross-org support — access resources in different ADO organizations
- Full audit trail
- Works with
AzureCLI@3task — native pipeline integration - Supports REST API calls, repo checkout, artifact feeds, extension publishing
- Requires Basic license for the service principal in each target org
- Rolling out progressively — may not be available in all orgs yet
- Requires Entra app registration (or managed identity) setup
- Create a Service Principal or Managed Identity in Entra ID
- Add it to the Azure DevOps organization as a user (Organization Settings → Users)
- Assign permissions (e.g., Advanced Security: Read alerts)
- Create an "Azure DevOps" service connection (Project Settings → Service Connections → New → Azure DevOps)
- Configure federated credentials automatically
steps:
- task: AzureCLI@3
displayName: 'Call ADO REST API via Service Connection'
inputs:
connectionType: 'azureDevOps'
azureDevOpsServiceConnection: 'my-azdo-connection'
scriptType: 'pscore'
scriptLocation: 'inlineScript'
inlineScript: |
# Get Entra access token for Azure DevOps
$token = az account get-access-token `
--resource "499b84ac-1321-427f-aa17-267ca6975798" `
--query "accessToken" --output tsv
$headers = @{
Authorization = "Bearer $token"
"Content-Type" = "application/json"
}
# Example: query Advanced Security alerts
$alerts = Invoke-RestMethod `
-Uri "https://advsec.dev.azure.com/{org}/{project}/_apis/alert/repositories/{repoId}/alerts?api-version=7.2-preview.1" `
-Headers $headers
Write-Host "Total alerts: $($alerts.count)"
if ($alerts.value | Where-Object { $_.severity -in @('high','critical') -and $_.state -eq 'active' }) {
Write-Error "High/Critical security alerts found — failing gate"
exit 1
}Use az account get-access-token with the Azure DevOps resource ID to get a short-lived Entra token. Can authenticate as a user, service principal, or managed identity.
- Quick scripting / one-off API calls
- Testing and development
- Migration from PAT-based scripts (drop-in replacement)
# Login as service principal
az login --service-principal -u {client-id} -p {client-secret} --tenant {tenant-id}
# Get token
TOKEN=$(az account get-access-token \
--resource 499b84ac-1321-427f-aa17-267ca6975798 \
--query accessToken -o tsv)
# Use in REST call
curl -s -H "Authorization: Bearer $TOKEN" \
"https://dev.azure.com/{org}/_apis/projects?api-version=7.2"Your customer uses the Advanced Security REST APIs in pipelines to create a quality gate (checking for high/critical alerts before allowing deployments).
| Date | Event |
|---|---|
| Sprint 269 (early 2026) | Build identity access restricted for Advanced Security APIs |
| March 2026 | Temporary rollback — build identities can access Advanced Security: Read alerts again |
| April 15, 2026 |
Restriction re-enforced — build identities permanently blocked from Advanced Security APIs |
| Sprint 271 (March 2026) | Status Checks ship — native PR gating on security posture without API calls |
- Register a Service Principal in Entra ID
- Add it to ADO with a Basic license
- Grant "Advanced Security: Read alerts" permission on target repositories
- Use it from the pipeline via Service Connection or direct token acquisition
- Key insight from Microsoft: "If the service principal isn't committing code, it won't consume an Advanced Security committer license" — you only pay the Basic license (~$6/month), NOT the GHAzDO per-committer fee
- Native branch policy — no API calls needed
- Two check types:
AdvancedSecurity/NewHighAndCritical— fails only for NEW alerts introduced by the PRAdvancedSecurity/AllHighAndCritical— fails if ANY high/critical alert exists
- Configure as a branch policy (Settings → Branch Policies → Status Checks)
- Fail-open if Advanced Security is not enabled on the repo
- Limitation: Only blocks PRs — does not cover post-merge deployment gates or custom logic
- Note: Advanced Security product license is still required on the repos; "free" refers to no additional SP/Basic license cost
- Use Status Checks for PR-time gating (simpler, no additional SP licensing cost)
- Use a Service Principal for deployment-gate scenarios or custom alert processing logic that goes beyond simple pass/fail
| Access Level | Cost | Capabilities |
|---|---|---|
| Stakeholder | Free | ❌ Cannot access repos (private projects), ❌ Limited REST API access (no Repos, Pipelines, Artifacts APIs) |
| Basic ✅ | ~$6/user/month | ✅ Full API access, ✅ Repo access, ✅ Advanced Security read |
| Basic + Test Plans | ~$52/user/month | Same + Test Plans (unnecessary for automation) |
- Service Principals require at least Basic to access repos and most REST APIs. Stakeholder is NOT sufficient.
- Multi-organization billing does NOT apply to service principals. You pay per org.
- Group-based licensing rules don't auto-apply to SPs. You must assign the access level directly.
- Advanced Security committer license is NOT consumed if the SP only reads alerts (doesn't commit code).
- One SP can serve multiple pipelines — you don't need one per pipeline.
- Use one Service Principal across all pipelines in an organization
- If cross-org, you pay Basic in each org (~$6/month each)
- For PR gating only, consider Status Checks (Sprint 271+) — zero SP licensing cost (Advanced Security product license still required on the repos)
| Date | Change | Impact |
|---|---|---|
| 2024 | Azure DevOps encourages Entra tokens over PATs | Guidance only |
| April 2025 | No new Azure DevOps OAuth app registrations | New apps must use Entra OAuth |
| 2025 | "Generate Git Credentials" button removed from Repos/Wiki UI | Minor — PAT creation still possible manually |
| April 15, 2026 |
Build identities blocked from Advanced Security APIs | Must migrate to SP/MI |
| 2026 (target) | Azure DevOps OAuth platform end-of-life targeted; specific date to be announced by Microsoft | All apps must use Entra OAuth |
| Future | PAT creation policies (allow-list only) | Org admins can restrict who creates PATs |
| Approach | Effort | Cost | Security | Recommendation |
|---|---|---|---|---|
| Status Checks (Sprint 271+) | 🟢 Low | 🟢 Free (no SP cost) | 🟢 High | ✅ Best for PR gating |
| ADO Service Connection + SP | 🟡 Medium | 🟡 ~$6/mo | 🟢 High | ✅ Best for deployment gates & custom logic |
| Direct SP token in script | 🟡 Medium | 🟡 ~$6/mo | 🟡 Medium (secret mgmt) | |
| Managed Identity | 🟡 Medium | 🟡 ~$6/mo | 🟢 High | ✅ Best if using Azure-hosted self-hosted agents |
| System.AccessToken | 🟢 Low | 🟢 Free | 🔴 Blocked | ❌ Won't work after April 15, 2026 |
| PAT | 🟢 Low | 🟢 Free | 🔴 Low | ❌ Discouraged by Microsoft |
| Scenario | Recommended Method |
|---|---|
| Simple in-org API calls (non-security) | System.AccessToken (still works for most APIs) |
| Advanced Security API automation | Service Principal via ADO Service Connection |
| Cross-organization access | ADO Service Connection with Workload Identity Federation |
| Azure-hosted self-hosted agents | Managed Identity |
| Deployment gates with custom logic | Service Principal + Entra token |
| PR-time security gating | Status Checks (Sprint 271+) |
# azure-pipelines.yml
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
steps:
- task: AzureCLI@3
displayName: 'Advanced Security Gate'
inputs:
connectionType: 'azureDevOps'
azureDevOpsServiceConnection: 'advsec-gate-connection'
scriptType: 'pscore'
scriptLocation: 'inlineScript'
inlineScript: |
$token = az account get-access-token `
--resource "499b84ac-1321-427f-aa17-267ca6975798" `
--query "accessToken" --output tsv
$headers = @{
Authorization = "Bearer $token"
"Content-Type" = "application/json"
}
$org = "$(System.CollectionUri)" -replace 'https://dev.azure.com/', '' -replace '/$', ''
$project = "$(System.TeamProject)"
# Get repository ID
$repos = Invoke-RestMethod `
-Uri "https://dev.azure.com/$org/$project/_apis/git/repositories?api-version=7.2" `
-Headers $headers
$repoId = ($repos.value | Where-Object { $_.name -eq "$(Build.Repository.Name)" }).id
# Query Advanced Security alerts
$alerts = Invoke-RestMethod `
-Uri "https://advsec.dev.azure.com/$org/$project/_apis/alert/repositories/$repoId/alerts?criteria.states=active&criteria.severities=critical,high&api-version=7.2-preview.1" `
-Headers $headers
$activeHighCritical = $alerts.value | Where-Object {
$_.severity -in @('critical', 'high') -and $_.state -eq 'active'
}
if ($activeHighCritical.Count -gt 0) {
Write-Host "##vso[task.logissue type=error]Found $($activeHighCritical.Count) active high/critical alerts"
foreach ($alert in $activeHighCritical) {
Write-Host " - [$($alert.severity)] $($alert.title)"
}
exit 1
}
Write-Host "✅ No active high/critical security alerts"Configuration Steps:
1. Enable Advanced Security on the repository
2. Go to Project Settings → Repos → {Repository} → Branch Policies → {branch}
3. Under "Status Checks", add:
- AdvancedSecurity/NewHighAndCritical (recommended for most teams)
- or AdvancedSecurity/AllHighAndCritical (stricter)
4. Set the policy to "Required"
No pipeline code, no service principal, no SP license cost.
Note: Advanced Security product license is still required on the repositories.
The customer has:
- 200 projects in a single Azure DevOps organization
- A custom pipeline task (VSIX extension) that calls Advanced Security APIs to create a security gate
- The task currently relies on
System.AccessToken(or a PAT) — both are dead ends for Advanced Security APIs after April 15, 2026
The custom task needs a new way to get an Entra token to call Advanced Security APIs. The challenge is doing this across 200 projects with minimal per-project configuration.
How it works: Modify the custom task's task.json to accept a service connection of type Azure DevOps. The task uses the Azure Pipelines Task SDK to retrieve the Entra token from the service connection at runtime.
Task code change (task.json):
{
"inputs": [
{
"name": "azureDevOpsServiceConnection",
"type": "connectedService:azuredevops",
"label": "Azure DevOps Service Connection",
"required": true,
"helpMarkDown": "Select an Azure DevOps service connection for Advanced Security API authentication."
}
]
}Task runtime (TypeScript / Node):
import * as tl from 'azure-pipelines-task-lib/task';
const endpointId = tl.getInput('azureDevOpsServiceConnection', true)!;
const token = tl.getEndpointAuthorizationParameter(endpointId, 'AccessToken', false)!;
// Use token for Advanced Security API call
const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };Rollout across 200 projects:
- Register 1 Service Principal in Entra → add to the ADO org with Basic license + Advanced Security: Read alerts permission
- Use the Azure DevOps REST API to script the creation of the service connection in all 200 projects:
# Script to create ADO Service Connection in all projects $org = "https://dev.azure.com/{org}" $projects = (Invoke-RestMethod -Uri "$org/_apis/projects?api-version=7.2" -Headers $headers).value foreach ($project in $projects) { # Create Azure DevOps service connection via REST API $body = @{ name = "advsec-gate-connection" type = "azuredevops" # ... service connection configuration } | ConvertTo-Json -Depth 5 Invoke-RestMethod -Uri "$org/$($project.name)/_apis/serviceendpoint/endpoints?api-version=7.2" ` -Method POST -Headers $headers -Body $body -ContentType "application/json" }
- Update pipeline YAML/definitions to pass the service connection name to the task
| Pros | Cons |
|---|---|
| ✅ Zero secrets in pipelines — WIF handles everything | ❌ Task code must be updated and republished |
| ✅ Per-pipeline scoping — fine-grained access control | ❌ Service connection needed in each project (scriptable) |
| ✅ Audit trail per pipeline | ❌ Pipeline definitions need updating to pass the SC input |
| ✅ 1 SP, 1 Basic license (~$6/mo total) |
How it works: Add an AzureCLI@3 step before the custom task that authenticates via the ADO service connection and stores the Entra token in a pipeline variable. The custom task reads the token from the variable — no changes to the task code's auth mechanism, only to where it reads the token from.
steps:
# Step 1: Acquire Entra token via service connection
- task: AzureCLI@3
name: GetAdvSecToken
displayName: 'Acquire Entra Token for Advanced Security'
inputs:
connectionType: 'azureDevOps'
azureDevOpsServiceConnection: 'advsec-gate-connection'
scriptType: 'pscore'
scriptLocation: 'inlineScript'
inlineScript: |
$token = az account get-access-token `
--resource "499b84ac-1321-427f-aa17-267ca6975798" `
--query "accessToken" --output tsv
Write-Host "##vso[task.setvariable variable=ADVSEC_TOKEN;issecret=true;isoutput=true]$token"
# Step 2: Custom task uses the token from the variable
- task: YourCustomAdvSecGate@1
inputs:
accessToken: $(GetAdvSecToken.ADVSEC_TOKEN)Minimal task change: The task just needs to accept a accessToken input instead of (or in addition to) using System.AccessToken. If it already accepts a token input (e.g., for PAT), this might be a config-only change.
| Pros | Cons |
|---|---|
| ✅ Minimal or zero task code changes | ❌ Two steps instead of one in every pipeline |
| ✅ Uses proven AzureCLI@3 auth mechanism | ❌ Token passed via variable (secret, but still a variable) |
| ✅ Same service connection setup as Option A | ❌ Pipeline YAML must still be updated |
| ✅ 1 SP, 1 Basic license (~$6/mo total) |
How it works: Store the Service Principal's client_id, tenant_id, and client_secret (or certificate) in a shared Azure Key Vault. Each project creates a variable group linked to that Key Vault. The custom task reads the credentials and acquires an Entra token directly.
variables:
- group: 'AdvSec-ServicePrincipal-KV' # Linked to shared Key Vault
steps:
- task: YourCustomAdvSecGate@1
inputs:
clientId: $(sp-client-id)
clientSecret: $(sp-client-secret)
tenantId: $(sp-tenant-id)Task acquires token internally:
// Inside the custom task
const tenantId = tl.getInput('tenantId', true)!;
const body = new URLSearchParams({
client_id: tl.getInput('clientId', true)!,
scope: 'https://app.vssps.visualstudio.com/.default',
client_secret: tl.getInput('clientSecret', true)!,
grant_type: 'client_credentials'
});
const tokenResponse = await fetch(
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
{ method: 'POST', body }
);
const { access_token } = await tokenResponse.json();Rollout across 200 projects:
- Create 1 Azure Key Vault with SP credentials
- Create 1 ARM service connection (for Key Vault access) — can potentially be shared or scripted
- Script the creation of a Key Vault-linked variable group in each of the 200 projects via REST API
- Update pipeline YAML to reference the variable group
| Pros | Cons |
|---|---|
| ✅ Central secret management via Key Vault | ❌ Client secret exists (needs rotation) — use certificate to mitigate |
| ✅ Variable groups scriptable via REST API | ❌ Variable groups are project-scoped — must create in each project |
| ✅ Task gets full control over token lifecycle | ❌ Task code needs updating to do token acquisition |
| ✅ 1 SP, 1 Basic license (~$6/mo total) | ❌ Additional ARM service connection needed for Key Vault |
How it works: Replace the custom pipeline task with the native Advanced Security Status Checks (Sprint 271+) configured as branch policies. No code, no task, no service principal, no SP license cost. (Advanced Security product license is still required on the repos.)
Rollout across 200 projects:
- Script the status check policy via the ADO REST API for all repositories:
# For each repo in each project, set the branch policy
$policyBody = @{
isEnabled = $true
isBlocking = $true
type = @{ id = "status-check-policy-type-id" }
settings = @{
statusName = "AdvancedSecurity/NewHighAndCritical"
statusGenre = "AdvancedSecurity"
authorId = "" # leave empty for system
defaultDisplayName = "Advanced Security: No new high/critical alerts"
scope = @(@{
repositoryId = $repoId
refName = "refs/heads/main"
matchKind = "Exact"
})
}
} | ConvertTo-Json -Depth 10| Pros | Cons |
|---|---|
| ✅ Zero SP license cost — no SP needed (AdvSec product license still required) | ❌ PR gating only — no deployment gate support |
| ✅ Zero code, zero task maintenance | ❌ Only pass/fail on high/critical — no custom logic |
| ✅ Native integration with branch policies | ❌ Can't customize thresholds or alert categories |
| ✅ Scriptable across all 200 projects | ❌ Doesn't cover post-merge pipeline scenarios |
| If the customer needs... | Best approach | Rollout effort |
|---|---|---|
| PR gating only (block merges with high/critical alerts) | Option D: Status Checks (Sprint 271+) | 🟢 Low — script branch policies via REST API, no task changes |
| Deployment gates + custom logic with minimal task changes | Option B: Pre-step pattern | 🟡 Medium — script SCs, update pipeline YAML, minimal task change |
| Clean long-term solution with full task modernization | Option A: SC input on custom task | 🟡 Medium — update task + republish + script SCs + update YAML |
| No task changes at all but need deployment gates | Option C: Key Vault credentials | 🟡 Medium — script KV variable groups, update YAML only if task already accepts token input |
| Item | Option A | Option B | Option C | Option D |
|---|---|---|---|---|
| Service Principal | 1 | 1 | 1 | 0 |
| Basic License | ~$6/mo | ~$6/mo | ~$6/mo | $0 |
| AdvSec Committer License | $0 (read-only) | $0 (read-only) | $0 (read-only) | $0 |
| Service Connection provisioning | 200 (scriptable) | 200 (scriptable) | 200 + ARM SC | 0 |
| Task code changes | Yes | Minimal | Yes | None (retire task) |
| Pipeline YAML changes | Yes | Yes | Yes | No (branch policy) |
For Options A, B, or C, use the Azure DevOps REST API to provision service connections across all 200 projects in one script run. A reference script outline:
# Authenticate as org admin
$orgUrl = "https://dev.azure.com/{org}"
$adminToken = az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv
$headers = @{ Authorization = "Bearer $adminToken"; "Content-Type" = "application/json" }
# Get all projects
$projects = (Invoke-RestMethod -Uri "$orgUrl/_apis/projects?`$top=500&api-version=7.2" -Headers $headers).value
Write-Host "Found $($projects.Count) projects"
foreach ($project in $projects) {
Write-Host "Provisioning service connection in: $($project.name)"
# Check if SC already exists
$existing = (Invoke-RestMethod -Uri "$orgUrl/$($project.name)/_apis/serviceendpoint/endpoints?endpointNames=advsec-gate-connection&api-version=7.2" -Headers $headers).value
if ($existing.Count -gt 0) {
Write-Host " → Already exists, skipping"
continue
}
# Create the Azure DevOps service connection
# (Exact payload depends on connection type and SP details)
$scBody = @{
name = "advsec-gate-connection"
type = "azuredevops"
url = $orgUrl
authorization = @{
scheme = "WorkloadIdentityFederation"
parameters = @{
servicePrincipalId = "{sp-client-id}"
tenantId = "{tenant-id}"
}
}
serviceEndpointProjectReferences = @(@{
projectReference = @{ id = $project.id; name = $project.name }
name = "advsec-gate-connection"
})
} | ConvertTo-Json -Depth 5
Invoke-RestMethod -Uri "$orgUrl/_apis/serviceendpoint/endpoints?api-version=7.2" `
-Method POST -Headers $headers -Body $scBody -ContentType "application/json"
Write-Host " → Created"
}The customer uses a pipeline decorator (not a custom task) that injects steps into every pipeline in the organization. The decorator currently uses System.AccessToken to call Advanced Security APIs and gate pipelines. After April 15, 2026, this will stop working.
Key constraint: the customer already has 200 service connections with SPs to cloud environments — but those are Azure Resource Manager SPs for deploying to Azure, not ADO-scoped SPs. Adding all 200 as Basic users in ADO would cost ~$1,200/month and is unnecessary.
Pipeline decorators have unique constraints that don't apply to regular pipeline tasks:
| Constraint | Impact |
|---|---|
| Service connection names must be hardcoded — no variables, no parameters, no runtime expressions | The SC name in the decorator YAML must be a literal string, resolved at compile time |
| Decorator runs on EVERY pipeline in the org | The referenced SC must be authorized in every project where pipelines run |
| Decorators cannot accept user inputs | Unlike custom tasks, there's no task.json with input fields — the decorator YAML is fixed |
| SC authorization is project-scoped | Even with "Grant access to all pipelines," that only applies within the project where the SC lives |
This means: the decorator must reference a single, hardcoded service connection name, and that SC must exist and be authorized in every project.
Proposal: Create 1 Service Principal with only Advanced Security: Read alerts at the org level, create 1 Azure DevOps Service Connection with Workload Identity Federation, and share it across all 200 projects with a consistent name (e.g., advsec-gate-sc).
Verdict: This is a sound approach. Here's the analysis:
| Aspect | Assessment |
|---|---|
| Security | ✅ The SP has the narrowest possible scope — only Advanced Security: Read alerts, org-wide. It cannot modify code, manage releases, access ARM resources, or change alert states. |
| Cost | ✅ Only 1 Basic license (~$6/month). No Advanced Security committer license since the SP doesn't commit code. |
| Shared SC concern | |
| Decorator compatibility | ✅ The SC name can be hardcoded in the decorator YAML since it's the same name everywhere. |
| Rollout | 🟡 Must create/share the SC in all 200 projects (scriptable via REST API). |
Key defense of this approach: The reason Microsoft discourages shared SCs is the principle of least privilege — a shared ARM SC could allow pipelines in Project A to deploy to Project B's Azure resources. But this SC has zero ARM access. It can only read ADO security alerts. The risk profile is fundamentally different.
| Risk | Severity | Mitigation |
|---|---|---|
| Alert data exposure — any pipeline in the org can read security alerts from any repo | 🟡 Medium | Accept if org policy allows central security visibility; if not, scope the SP's Read alerts permission per-project instead of org-wide |
| SC authorization sprawl — must grant "Use" to all pipelines in 200 projects | 🟡 Medium | Script it via REST API; use "Grant access to all pipelines" per project |
| Single point of failure — if the SC breaks, all 200 projects lose their gate | 🟡 Medium | Monitor the SC, set up alerts; WIF has no secrets to expire |
Decorator update required — decorator YAML must be changed from System.AccessToken to AzureCLI@3 with the SC |
🟢 Low | One-time change, centrally managed |
| SC doesn't exist yet in new projects — new projects need manual/automated provisioning | 🟡 Medium | Automate with a project creation hook or periodic script |
Alternative 1: Decorator Uses System.AccessToken + Explicitly Grant "Advanced Security: Read alerts" to Build Service
Status: ❌ Will NOT work after April 15, 2026. Microsoft is blocking build service identities at the API level regardless of explicit permissions. This is not a permission issue — it's an identity-type block.
| Aspect | Assessment |
|---|---|
| Cost | ❌ 200 × ~$6 = ~$1,200/month |
| Security | ✅ Better isolation per project |
| Rollout | ❌ 200 Entra app registrations + ADO provisioning |
| Decorator | ❌ Decorator can't dynamically select which SC to use — it must be hardcoded |
Verdict: Not viable. The decorator's hardcoded SC name means you NEED one shared SC anyway. Multiple SPs defeats the purpose and costs 200× more.
| Aspect | Assessment |
|---|---|
| Cost | ✅ $0 |
| Security | ✅ Native platform feature |
| Rollout | 🟡 Script branch policies across all repos via REST API |
| Flexibility | ❌ PR gating only — no deployment gate, no custom logic |
| Decorator | N/A — replaces the decorator entirely |
Verdict: Best for PR gating. But if the decorator does more than just pass/fail (custom severity thresholds, alert categorization, deployment gates, reporting), Status Checks won't replace it.
The decorator injects a script step that reads SP credentials from an Azure Key Vault-linked variable group, then acquires an Entra token directly. This avoids the SC-in-decorator limitation.
# decorator.yml
steps:
- ${{ if ne(variables['skipAdvSecGate'], 'true') }}:
- task: AzureKeyVault@2
inputs:
azureSubscription: 'keyvault-reader-sc' # ARM SC - already exists
KeyVaultName: 'advsec-sp-keyvault'
SecretsFilter: 'advsec-sp-client-id,advsec-sp-client-secret,advsec-sp-tenant-id'
RunAsPreJob: false
- task: PowerShell@2
displayName: 'Advanced Security Gate'
inputs:
targetType: 'inline'
script: |
# Acquire Entra token for the dedicated AdvSec SP
$body = @{
client_id = "$(advsec-sp-client-id)"
scope = "https://app.vssps.visualstudio.com/.default"
client_secret = "$(advsec-sp-client-secret)"
grant_type = "client_credentials"
}
$token = (Invoke-RestMethod -Uri "https://login.microsoftonline.com/$(advsec-sp-tenant-id)/oauth2/v2.0/token" -Method POST -Body $body).access_token
# ... call Advanced Security API with $token ...| Aspect | Assessment |
|---|---|
| Cost | ✅ 1 Basic license (~$6/month) + Key Vault costs (negligible) |
| Security | |
| SC dependency | |
| Decorator compat | ✅ Works if the ARM SC has the same name in all projects (common pattern) |
Verdict: Viable fallback if WIF is not available or the "Azure DevOps" SC type hasn't rolled out yet. But adds secret management complexity.
For your specific scenario (pipeline decorator, 200 projects, April 15 deadline):
┌─────────────────────────────────────────────────────────────┐
│ RECOMMENDED: Your proposed solution with refinements │
│ │
│ 1 Service Principal (Entra) │
│ └── Advanced Security: Read alerts (org-wide) │
│ └── Basic license ($6/month) │
│ └── No code commit = No AdvSec committer license │
│ │
│ 1 Azure DevOps Service Connection (WIF) │
│ └── Name: "advsec-gate-sc" (hardcoded in decorator) │
│ └── Zero secrets (Workload Identity Federation) │
│ └── Shared to all 200 projects via REST API │
│ └── "Grant access to all pipelines" per project │
│ │
│ Updated Decorator (one-time change) │
│ └── Replace System.AccessToken usage with AzureCLI@3 │
│ └── Reference: azureDevOpsServiceConnection: advsec-gate-sc │
│ └── Republish extension │
│ │
│ Total cost: ~$6/month │
│ Total SPs as Basic users: 1 (not 200) │
└─────────────────────────────────────────────────────────────┘
# decorator.yml — updated to use ADO Service Connection
steps:
- ${{ if ne(variables['skipAdvSecGate'], 'true') }}:
- task: AzureCLI@3
displayName: 'Advanced Security Gate (Decorator)'
inputs:
connectionType: 'azureDevOps'
azureDevOpsServiceConnection: 'advsec-gate-sc'
scriptType: 'pscore'
scriptLocation: 'inlineScript'
inlineScript: |
$token = az account get-access-token `
--resource "499b84ac-1321-427f-aa17-267ca6975798" `
--query "accessToken" --output tsv
$headers = @{
Authorization = "Bearer $token"
"Content-Type" = "application/json"
}
# Dynamically resolve org and project from pipeline context
$collectionUri = $env:SYSTEM_COLLECTIONURI
$project = $env:SYSTEM_TEAMPROJECT
$repoName = $env:BUILD_REPOSITORY_NAME
$org = ($collectionUri -replace 'https://dev.azure.com/', '' -replace '/$', '')
# Get repository ID
$repos = Invoke-RestMethod `
-Uri "${collectionUri}${project}/_apis/git/repositories?api-version=7.2" `
-Headers $headers
$repoId = ($repos.value | Where-Object { $_.name -eq $repoName }).id
if (-not $repoId) {
Write-Host "##vso[task.logissue type=warning]Repository not found in ADO, skipping Advanced Security gate"
exit 0
}
# Query Advanced Security alerts
$alerts = Invoke-RestMethod `
-Uri "https://advsec.dev.azure.com/$org/$project/_apis/alert/repositories/$repoId/alerts?criteria.states=active&criteria.severities=critical,high&api-version=7.2-preview.1" `
-Headers $headers
$activeHighCritical = @($alerts.value | Where-Object {
$_.severity -in @('critical', 'high') -and $_.state -eq 'active'
})
if ($activeHighCritical.Count -gt 0) {
Write-Host "##vso[task.logissue type=error]Found $($activeHighCritical.Count) active high/critical security alerts"
foreach ($a in $activeHighCritical) {
Write-Host " - [$($a.severity)] $($a.title)"
}
Write-Host "##vso[task.complete result=Failed;]Advanced Security Gate FAILED"
exit 1
}
Write-Host "✅ No active high/critical security alerts — gate passed"# Run once: provision the shared service connection across all projects
param(
[string]$OrgUrl = "https://dev.azure.com/{org}",
[string]$ServiceConnectionName = "advsec-gate-sc"
)
# Authenticate
az login --allow-no-subscriptions
$token = az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv
$headers = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }
# Get all projects (handle pagination for 200+)
$allProjects = @()
$continuationToken = $null
do {
$uri = "$OrgUrl/_apis/projects?`$top=100&api-version=7.2"
if ($continuationToken) { $uri += "&continuationToken=$continuationToken" }
$response = Invoke-WebRequest -Uri $uri -Headers $headers
$projects = ($response.Content | ConvertFrom-Json).value
$allProjects += $projects
$continuationToken = $response.Headers['x-ms-continuationtoken']
} while ($continuationToken)
Write-Host "Found $($allProjects.Count) projects"
# Check if SC already exists in each project; if not, create or share it
foreach ($project in $allProjects) {
$checkUri = "$OrgUrl/$($project.name)/_apis/serviceendpoint/endpoints?endpointNames=$ServiceConnectionName&api-version=7.2"
$existing = (Invoke-RestMethod -Uri $checkUri -Headers $headers).value
if ($existing.Count -gt 0) {
Write-Host " [$($project.name)] SC already exists — ensuring authorization..."
# Authorize for all pipelines in this project
$authBody = @{
allPipelines = @{ authorized = $true }
resource = @{ type = "endpoint"; id = $existing[0].id }
} | ConvertTo-Json -Depth 5
Invoke-RestMethod -Uri "$OrgUrl/$($project.name)/_apis/pipelines/pipelinepermissions/endpoint/$($existing[0].id)?api-version=7.2-preview.1" `
-Method PATCH -Headers $headers -Body $authBody -ContentType "application/json" | Out-Null
Write-Host " [$($project.name)] ✅ Authorized"
} else {
Write-Host " [$($project.name)] Creating SC..."
# Share the existing SC to this project by adding a project reference
# (Requires the SC to exist in at least one project first)
# Exact payload depends on ADO Service Connection type
Write-Host " [$($project.name)] ⚠️ Manual share or REST creation needed"
}
}This section explains the security rationale behind Microsoft's Sprint 269 decision to block build service identities from Advanced Security APIs. The findings below are based on verified technical analysis of the Azure Pipelines Task SDK, Microsoft documentation, and actual marketplace extension source code.
Prior to Sprint 269, any pipeline running with System.AccessToken could read Advanced Security alerts because the Build Service identity had implicit API access by default. This created a critical security risk:
Attack Chain:
Any installed pipeline task/extension (including marketplace)
→ Calls tl.getEndpointAuthorizationParameter('SYSTEMVSSCONNECTION', 'AccessToken', false)
→ Receives the Build Service identity OAuth token (no user opt-in required)
→ Calls Advanced Security API (allowed by default pre-Sprint 269)
→ Reads all security alerts (vulnerabilities, secrets, dependencies)
→ Can exfiltrate sensitive vulnerability data to external services
There are three ways a pipeline component can access the Build Service OAuth token. Understanding the distinction is critical:
| Mechanism | Who Can Use It | User Opt-in Required? | Severity |
|---|---|---|---|
tl.getEndpointAuthorizationParameter('SYSTEMVSSCONNECTION', 'AccessToken', false) |
Any pipeline task via the Task SDK | ❌ No — built-in system endpoint, always available | 🔴 Critical |
tl.getVariable('System.AccessToken') |
Any pipeline task via the Task SDK | ❌ No — pipeline variables are accessible to tasks | 🔴 Critical |
process.env['SYSTEM_ACCESSTOKEN'] |
Scripts (inline bash/PS) | ✅ Yes — requires env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) or "Allow scripts to access OAuth token" |
🟡 Medium |
Key finding: SYSTEMVSSCONNECTION is a built-in system service endpoint that Azure Pipelines exposes to every task in every job. It represents the Build Service identity. Any pipeline task — including marketplace extensions — can call tl.getEndpointAuthorizationParameter('SYSTEMVSSCONNECTION', 'AccessToken', false) to obtain the OAuth token without any user configuration or opt-in.
The "Allow scripts to access the OAuth token" setting only applies to script steps (inline PowerShell, Bash, CMD). It does NOT restrict task-level access via the Task SDK. This distinction is often misunderstood.
A marketplace extension does NOT need to declare any special permissions or service connection inputs to access the Build Service token. The task code simply needs:
import * as tl from 'azure-pipelines-task-lib/task';
// No user opt-in, no service connection input, no special permission
const token = tl.getEndpointAuthorizationParameter('SYSTEMVSSCONNECTION', 'AccessToken', false);
// This token authenticates as the Build Service identity
// Pre-Sprint 269: could call Advanced Security APIs with this token
const response = await fetch(
`https://advsec.dev.azure.com/{org}/{project}/_apis/alert/repositories/{repoId}/alerts?api-version=7.2-preview.1`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
// → Returns all security alerts: code vulnerabilities, exposed secrets, dependency CVEsVerified by source code analysis: Popular marketplace extensions (e.g., token replacement, code analysis, deployment tools) typically do NOT access SYSTEMVSSCONNECTION or call ADO REST APIs beyond their stated purpose. However, nothing in the platform prevents them from doing so — there is no sandboxing, no API allowlist per extension, and no runtime permission gate.
Factor 1: Shared Identity — No Pipeline Isolation
The Build Service identity (Project Collection Build Service or {Project} Build Service) is shared across ALL pipelines in a project (or the entire collection if not restricted). Every task in every pipeline gets the same identity with the same permissions.
From Microsoft Learn: "The permissions of this token are based on the Project Build Service identity, meaning all job access tokens in a project have identical permissions."
Factor 2: Advanced Security API Access Was Not Explicitly Gated
Prior to Sprint 269, Build Service identities were not blocked at the API level from calling Advanced Security endpoints. The Sprint 269 release notes confirm this was the change: "Advanced Security REST APIs no longer accept build service identities." The rollback blog further states the restriction was "a security improvement" that was rolled back because customers relied on this access for automation. This means pipelines using System.AccessToken could call the Advanced Security API without any administrator having explicitly granted Advanced Security: Read alerts to the Build Service.
Factor 3: No Extension Sandboxing
Azure DevOps does not sandbox pipeline task execution. A task runs as a Node.js process on the agent with full access to:
- The
SYSTEMVSSCONNECTIONtoken - The agent's file system (build sources, artifacts, secrets on disk)
- Outbound network access (can exfiltrate data)
- All environment variables set by previous tasks
The combination of these three factors creates a supply-chain attack vector:
1. Attacker publishes (or compromises) a popular marketplace extension
2. Extension update adds code to read SYSTEMVSSCONNECTION token
3. Extension calls Advanced Security API to harvest vulnerability data
4. Extension exfiltrates: which repos have unpatched CVEs, exposed secrets, code vulnerabilities
5. Attacker uses this intelligence to target the organization's weakest points
This is not theoretical — Microsoft's own pipeline security guidance explicitly warns:
From Microsoft Learn - Secure Pipelines: "If a malicious actor gains pipeline access in one project, and Build Service identities are insufficiently scoped, they could affect other projects' resources — escalating an initially limited breach into a wider compromise."
From the Sprint 269 release notes:
"Advanced Security REST APIs no longer accept build service identities (such as
Project Collection Build Service) as callers. This change prevents pipeline-based automation from accessing or modifying security alert data using build service accounts, reducing the risk of unintended alert state changes during CI/CD runs."
From the temporary rollback blog post:
"We restricted API access for build identities as a security improvement but failed to provide an early notice for customers that relied upon this for various automations."
Moving to a dedicated Service Principal with an Azure DevOps Service Connection resolves all three compounding factors:
| Factor | Build Service (old) | Service Principal (new) |
|---|---|---|
| Identity isolation | Shared across all pipelines in the project | Only the pipeline/task referencing the SC gets the token |
| Permission model | Implicit — Build Service had default API access | Explicit — SP must be granted Advanced Security: Read alerts specifically |
| Audit trail | Generic "Build Service" in logs | Named SP identity with Entra audit logs |
| Token access | Any task gets it via SYSTEMVSSCONNECTION |
Only the AzureCLI@3 step (or task with SC input) referencing the specific SC |
| Conditional Access | Not supported | ✅ Entra Conditional Access policies apply |
| Credential lifetime | Job duration (up to 48h) | 1 hour (Entra token), auto-refreshed |
| Extension risk | Any installed extension can read the token | Extensions cannot access the SP token unless they reference the SC by name |
| Resource | URL |
|---|---|
| Sprint 269 Release Notes — Build Identity Restriction | https://learn.microsoft.com/en-us/azure/devops/release-notes/2026/ghazdo/sprint-269-update |
| Sprint 271 Release Notes — Status Checks | https://learn.microsoft.com/en-us/azure/devops/release-notes/2026/ghazdo/sprint-271-update |
| PAT-less Authentication from Pipeline Tasks (Roadmap) | https://learn.microsoft.com/en-us/azure/devops/release-notes/roadmap/2025/new-service-connection |
| Sprint 254 Update — No New OAuth Apps | https://learn.microsoft.com/en-us/azure/devops/release-notes/2025/general/sprint-254-update |
| Resource | URL |
|---|---|
| Reducing PAT Usage Across Azure DevOps | https://devblogs.microsoft.com/devops/reducing-pat-usage-across-azure-devops/ |
| Temporary Rollback: Build Identities & Advanced Security | https://devblogs.microsoft.com/devops/temporary-rollback-build-identities-can-access-advanced-security-read-alerts-again/ |
| No New Azure DevOps OAuth Apps (April 2025) | https://devblogs.microsoft.com/devops/no-new-azure-devops-oauth-apps/ |
| Resource | URL |
|---|---|
| Azure DevOps Auth Samples (Service Principals) | https://github.com/microsoft/azure-devops-auth-samples/tree/master/ServicePrincipalsSamples |
| Azure Pipelines Decorator Samples | https://github.com/n3wt0n/AzurePipelinesDecoratorSamples |
Document generated April 8, 2026. Information is based on publicly available Microsoft documentation and blog posts. Reviewed for accuracy by three independent AI models (GPT-5.3-Codex, Claude Haiku 4.5, Claude Sonnet 4.5). Verify current sprint feature availability with your Microsoft account team.