PAT Token Management and Rotation Strategies
Comprehensive guide to managing Personal Access Tokens in Azure DevOps, covering creation, scoping, lifecycle monitoring, automated rotation, secure storage, and auditing patterns.
PAT Token Management and Rotation Strategies
Overview
Personal Access Tokens are the most common authentication method for Azure DevOps automation, and also the most commonly mismanaged. Every organization I have worked with has at least a few PATs created by someone who left the company years ago, with full-scope access, no expiration, stored in a shared text file. This article covers how to manage PATs properly — creation with minimal scopes, lifecycle monitoring, automated rotation, secure storage in Key Vault, and auditing who is using what.
Prerequisites
- An Azure DevOps organization with Project Collection Administrator or Organization Owner permissions for token management APIs
- Node.js 16 or later for automation scripts
- Azure CLI installed for Key Vault operations
- An Azure Key Vault instance for secure token storage
- Basic familiarity with the Azure DevOps REST API and PAT authentication
PAT Scopes and Least-Privilege Patterns
Every PAT should have the minimum scopes required for its intended use. Azure DevOps offers granular scopes — use them.
Common Scope Combinations
| Use Case | Scopes | Notes |
|---|---|---|
| Read-only pipeline status | vso.build |
Build (read) only |
| Trigger pipeline runs | vso.build_execute |
Build (read and execute) |
| Read work items | vso.work |
Work items (read) |
| Create work items | vso.work_write |
Work items (read, write) |
| Read/clone repos | vso.code |
Code (read) |
| Push to repos | vso.code_write |
Code (read, write) |
| Manage service hooks | vso.hooks |
Service hooks (read) |
| Full API access | vso.full_access |
Never use this |
Creating Scoped PATs via the UI
- Go to User Settings (gear icon) > Personal Access Tokens
- Click + New Token
- Set a descriptive name:
ci-pipeline-build-trigger-prod - Choose the Organization scope (single org is more secure than all orgs)
- Set expiration to 90 days maximum
- Select Custom defined scopes and pick only what is needed
- Click Create and copy the token immediately — you cannot view it again
Creating PATs via REST API
The PAT Lifecycle Management API lets you create, list, and revoke tokens programmatically:
// scripts/create-pat.js
var https = require("https");
var AZURE_AD_TOKEN = process.env.AZURE_AD_TOKEN; // Entra ID bearer token
var ORG = process.env.AZURE_ORG;
function createPAT(displayName, scopes, validDays, callback) {
var validTo = new Date(Date.now() + validDays * 24 * 60 * 60 * 1000).toISOString();
var body = JSON.stringify({
displayName: displayName,
scope: scopes.join(" "),
validTo: validTo,
allOrgs: false
});
var options = {
hostname: "vssps.dev.azure.com",
path: "/" + ORG + "/_apis/tokens/pats?api-version=7.1-preview.1",
method: "POST",
headers: {
"Authorization": "Bearer " + AZURE_AD_TOKEN,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode >= 200 && res.statusCode < 300) {
var result = JSON.parse(data);
callback(null, {
authorizationId: result.patToken.authorizationId,
displayName: result.patToken.displayName,
token: result.patToken.token,
validTo: result.patToken.validTo,
scope: result.patToken.scope
});
} else {
callback(new Error("Create PAT failed (" + res.statusCode + "): " + data));
}
});
});
req.on("error", callback);
req.write(body);
req.end();
}
// Create a build-only PAT valid for 90 days
createPAT(
"ci-build-trigger-" + new Date().toISOString().substring(0, 10),
["vso.build_execute"],
90,
function(err, pat) {
if (err) {
console.error("Failed:", err.message);
process.exit(1);
}
console.log("PAT created successfully:");
console.log(" Name: " + pat.displayName);
console.log(" ID: " + pat.authorizationId);
console.log(" Expires: " + pat.validTo);
console.log(" Token: " + pat.token.substring(0, 10) + "...");
}
);
PAT Lifecycle Management and Expiration Monitoring
Stale PATs are a security risk. Build monitoring scripts that run on a schedule to flag tokens nearing expiration.
Listing All PATs for a User
// scripts/list-pats.js
var https = require("https");
var AZURE_AD_TOKEN = process.env.AZURE_AD_TOKEN;
var ORG = process.env.AZURE_ORG;
function listPATs(callback) {
var options = {
hostname: "vssps.dev.azure.com",
path: "/" + ORG + "/_apis/tokens/pats?api-version=7.1-preview.1",
method: "GET",
headers: {
"Authorization": "Bearer " + AZURE_AD_TOKEN,
"Accept": "application/json"
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode === 200) {
var result = JSON.parse(data);
callback(null, result.patTokens || []);
} else {
callback(new Error("List PATs failed: " + res.statusCode));
}
});
});
req.on("error", callback);
req.end();
}
listPATs(function(err, tokens) {
if (err) {
console.error("Error:", err.message);
process.exit(1);
}
console.log("Found " + tokens.length + " PAT(s):\n");
var now = new Date();
tokens.forEach(function(pat) {
var validTo = new Date(pat.validTo);
var daysLeft = Math.ceil((validTo - now) / (1000 * 60 * 60 * 24));
var status = daysLeft <= 0 ? "EXPIRED" : daysLeft <= 30 ? "EXPIRING SOON" : "OK";
console.log(" " + pat.displayName);
console.log(" ID: " + pat.authorizationId);
console.log(" Scope: " + pat.scope);
console.log(" Expires: " + validTo.toISOString().substring(0, 10) + " (" + daysLeft + " days)");
console.log(" Status: " + status);
console.log("");
});
// Summary
var expired = tokens.filter(function(t) { return new Date(t.validTo) <= now; }).length;
var expiring = tokens.filter(function(t) {
var d = Math.ceil((new Date(t.validTo) - now) / (1000 * 60 * 60 * 24));
return d > 0 && d <= 30;
}).length;
console.log("Summary: " + expired + " expired, " + expiring + " expiring within 30 days, " + tokens.length + " total");
});
Output:
Found 5 PAT(s):
ci-build-trigger-2026-01-15
ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Scope: vso.build_execute
Expires: 2026-04-15 (64 days)
Status: OK
repo-clone-automation
ID: b2c3d4e5-f6a7-8901-bcde-f12345678901
Scope: vso.code
Expires: 2026-02-28 (18 days)
Status: EXPIRING SOON
legacy-full-access-bob
ID: c3d4e5f6-a7b8-9012-cdef-123456789012
Scope: app_token
Expires: 2025-06-01 (-254 days)
Status: EXPIRED
Summary: 1 expired, 1 expiring within 30 days, 5 total
Scheduled Monitoring with Slack Alerts
// scripts/pat-monitor.js
var https = require("https");
var AZURE_AD_TOKEN = process.env.AZURE_AD_TOKEN;
var ORG = process.env.AZURE_ORG;
var SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL;
var ALERT_THRESHOLD_DAYS = parseInt(process.env.ALERT_THRESHOLD_DAYS, 10) || 30;
function listPATs(callback) {
var options = {
hostname: "vssps.dev.azure.com",
path: "/" + ORG + "/_apis/tokens/pats?api-version=7.1-preview.1",
method: "GET",
headers: {
"Authorization": "Bearer " + AZURE_AD_TOKEN,
"Accept": "application/json"
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode === 200) {
callback(null, JSON.parse(data).patTokens || []);
} else {
callback(new Error("API error: " + res.statusCode));
}
});
});
req.on("error", callback);
req.end();
}
function sendSlackAlert(message, callback) {
if (!SLACK_WEBHOOK) {
console.log("[Slack] No webhook configured. Message:\n" + message);
return callback(null);
}
var body = JSON.stringify({ text: message });
var parsed = new URL(SLACK_WEBHOOK);
var options = {
hostname: parsed.hostname,
path: parsed.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() { callback(null); });
});
req.on("error", callback);
req.write(body);
req.end();
}
listPATs(function(err, tokens) {
if (err) {
console.error("Failed to list PATs:", err.message);
process.exit(1);
}
var now = new Date();
var alerts = [];
tokens.forEach(function(pat) {
var validTo = new Date(pat.validTo);
var daysLeft = Math.ceil((validTo - now) / (1000 * 60 * 60 * 24));
if (daysLeft <= 0) {
alerts.push(":red_circle: *EXPIRED* — `" + pat.displayName + "` expired " + Math.abs(daysLeft) + " days ago");
} else if (daysLeft <= ALERT_THRESHOLD_DAYS) {
alerts.push(":warning: *EXPIRING* — `" + pat.displayName + "` expires in " + daysLeft + " days (" + validTo.toISOString().substring(0, 10) + ")");
}
});
if (alerts.length === 0) {
console.log("All " + tokens.length + " PATs are healthy. No alerts needed.");
return;
}
var message = ":key: *Azure DevOps PAT Expiration Alert*\n" +
"Organization: `" + ORG + "`\n\n" +
alerts.join("\n") + "\n\n" +
"_" + alerts.length + " token(s) need attention out of " + tokens.length + " total._\n" +
"_Rotate at: User Settings > Personal Access Tokens_";
sendSlackAlert(message, function(err2) {
if (err2) { console.error("Slack send failed:", err2.message); }
else { console.log("Alert sent for " + alerts.length + " token(s)"); }
});
});
Run daily via a scheduled pipeline:
# pat-monitor-pipeline.yml
schedules:
- cron: "0 8 * * 1-5"
displayName: "Weekday PAT health check"
branches:
include:
- main
always: true
pool:
vmImage: "ubuntu-latest"
steps:
- script: node scripts/pat-monitor.js
displayName: "Check PAT expirations"
env:
AZURE_AD_TOKEN: $(AzureADToken)
AZURE_ORG: $(System.CollectionUri)
SLACK_WEBHOOK_URL: $(SlackWebhookUrl)
ALERT_THRESHOLD_DAYS: 30
Automated Rotation with Key Vault Storage
The complete rotation flow: detect expiring PAT, create a new one, store in Key Vault, revoke the old one.
// scripts/rotate-pat.js
var https = require("https");
var cp = require("child_process");
var AZURE_AD_TOKEN = process.env.AZURE_AD_TOKEN;
var ORG = process.env.AZURE_ORG;
var VAULT_NAME = process.env.VAULT_NAME;
var PAT_SECRET_NAME = process.env.PAT_SECRET_NAME || "azure-devops-pat";
var PAT_DISPLAY_NAME = process.env.PAT_DISPLAY_NAME || "automated-pipeline-pat";
var PAT_SCOPES = (process.env.PAT_SCOPES || "vso.build_execute vso.code").split(" ");
var PAT_VALID_DAYS = parseInt(process.env.PAT_VALID_DAYS, 10) || 90;
function apiRequest(method, path, body, callback) {
var hostname = "vssps.dev.azure.com";
var bodyStr = body ? JSON.stringify(body) : null;
var options = {
hostname: hostname,
path: "/" + ORG + "/_apis/tokens/pats" + path,
method: method,
headers: {
"Authorization": "Bearer " + AZURE_AD_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json"
}
};
if (bodyStr) {
options.headers["Content-Length"] = Buffer.byteLength(bodyStr);
}
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode >= 200 && res.statusCode < 300) {
try { callback(null, JSON.parse(data)); }
catch (e) { callback(null, data); }
} else {
callback(new Error("API " + method + " " + path + " failed (" + res.statusCode + "): " + data));
}
});
});
req.on("error", callback);
if (bodyStr) { req.write(bodyStr); }
req.end();
}
function storeInKeyVault(secretName, secretValue, callback) {
var cmd = "az keyvault secret set" +
" --vault-name " + VAULT_NAME +
" --name " + secretName +
" --value \"" + secretValue + "\"" +
" --output json";
cp.exec(cmd, function(err, stdout, stderr) {
if (err) {
callback(new Error("Key Vault store failed: " + stderr));
} else {
var result = JSON.parse(stdout);
callback(null, result.id);
}
});
}
function rotatePAT() {
console.log("=== PAT Rotation Started ===");
console.log("Org: " + ORG);
console.log("Vault: " + VAULT_NAME);
console.log("Secret: " + PAT_SECRET_NAME);
console.log("");
// Step 1: List existing PATs to find the one to rotate
apiRequest("GET", "?api-version=7.1-preview.1", null, function(err, data) {
if (err) {
console.error("Failed to list PATs:", err.message);
process.exit(1);
}
var tokens = data.patTokens || [];
var existingPat = null;
tokens.forEach(function(t) {
if (t.displayName === PAT_DISPLAY_NAME) {
existingPat = t;
}
});
if (existingPat) {
console.log("Found existing PAT: " + existingPat.displayName);
console.log(" ID: " + existingPat.authorizationId);
console.log(" Expires: " + existingPat.validTo);
} else {
console.log("No existing PAT found with name: " + PAT_DISPLAY_NAME);
}
// Step 2: Create new PAT
var validTo = new Date(Date.now() + PAT_VALID_DAYS * 24 * 60 * 60 * 1000).toISOString();
var newName = PAT_DISPLAY_NAME + "-" + new Date().toISOString().substring(0, 10);
console.log("\nCreating new PAT: " + newName);
console.log(" Scopes: " + PAT_SCOPES.join(", "));
console.log(" Valid until: " + validTo.substring(0, 10));
var createBody = {
displayName: newName,
scope: PAT_SCOPES.join(" "),
validTo: validTo,
allOrgs: false
};
apiRequest("POST", "?api-version=7.1-preview.1", createBody, function(err2, result) {
if (err2) {
console.error("Failed to create PAT:", err2.message);
process.exit(1);
}
var newToken = result.patToken.token;
var newId = result.patToken.authorizationId;
console.log(" New PAT ID: " + newId);
console.log(" Token: " + newToken.substring(0, 10) + "...");
// Step 3: Store in Key Vault
console.log("\nStoring new PAT in Key Vault...");
storeInKeyVault(PAT_SECRET_NAME, newToken, function(err3, secretId) {
if (err3) {
console.error("Failed to store in Key Vault:", err3.message);
console.error("WARNING: New PAT created but not stored. Manual intervention needed.");
console.error("PAT ID: " + newId);
process.exit(1);
}
console.log(" Stored as: " + secretId);
// Step 4: Revoke old PAT
if (existingPat) {
console.log("\nRevoking old PAT: " + existingPat.authorizationId);
apiRequest("DELETE", "?authorizationId=" + existingPat.authorizationId + "&api-version=7.1-preview.1", null, function(err4) {
if (err4) {
console.error("Failed to revoke old PAT:", err4.message);
console.error("Old PAT still active. Revoke manually: " + existingPat.authorizationId);
} else {
console.log(" Old PAT revoked successfully");
}
console.log("\n=== PAT Rotation Complete ===");
console.log("New PAT active and stored in Key Vault.");
console.log("Next rotation due: " + validTo.substring(0, 10));
});
} else {
console.log("\n=== PAT Rotation Complete ===");
console.log("New PAT created and stored in Key Vault.");
console.log("Next rotation due: " + validTo.substring(0, 10));
}
});
});
});
}
rotatePAT();
Output:
=== PAT Rotation Started ===
Org: my-org
Vault: kv-devops-pipeline
Secret: azure-devops-pat
Found existing PAT: automated-pipeline-pat-2025-11-15
ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Expires: 2026-02-13T00:00:00.000Z
Creating new PAT: automated-pipeline-pat-2026-02-10
Scopes: vso.build_execute, vso.code
Valid until: 2026-05-11
New PAT ID: d4e5f6a7-b8c9-0123-defg-456789012345
Token: eyJ0eXAi...
Storing new PAT in Key Vault...
Stored as: https://kv-devops-pipeline.vault.azure.net/secrets/azure-devops-pat/abc123
Revoking old PAT: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Old PAT revoked successfully
=== PAT Rotation Complete ===
New PAT active and stored in Key Vault.
Next rotation due: 2026-05-11
Auditing PAT Usage
Azure DevOps provides audit logs that track PAT creation, usage, and revocation. Query the audit API to build reports.
// scripts/audit-pats.js
var https = require("https");
var PAT = process.env.AZURE_PAT;
var ORG = process.env.AZURE_ORG;
function auditRequest(continuationToken, callback) {
var auth = Buffer.from(":" + PAT).toString("base64");
var startTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
var path = "/" + ORG + "/_apis/audit/auditlog?startTime=" + encodeURIComponent(startTime) +
"&api-version=7.1";
if (continuationToken) {
path += "&continuationToken=" + encodeURIComponent(continuationToken);
}
var options = {
hostname: "auditservice.dev.azure.com",
path: path,
method: "GET",
headers: {
"Authorization": "Basic " + auth,
"Accept": "application/json"
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode === 200) {
callback(null, JSON.parse(data));
} else {
callback(new Error("Audit API error: " + res.statusCode));
}
});
});
req.on("error", callback);
req.end();
}
function collectPATEvents(callback) {
var allEvents = [];
function fetchPage(token) {
auditRequest(token, function(err, data) {
if (err) return callback(err);
var events = (data.decoratedAuditLogEntries || []).filter(function(e) {
return e.actionId && e.actionId.indexOf("Token") !== -1;
});
allEvents = allEvents.concat(events);
if (data.continuationToken) {
fetchPage(data.continuationToken);
} else {
callback(null, allEvents);
}
});
}
fetchPage(null);
}
collectPATEvents(function(err, events) {
if (err) {
console.error("Audit query failed:", err.message);
process.exit(1);
}
console.log("PAT-related audit events (last 30 days): " + events.length + "\n");
events.forEach(function(event) {
console.log("[" + event.timestamp + "] " + event.actionId);
console.log(" Actor: " + (event.actorDisplayName || "unknown"));
console.log(" Details: " + (event.details || "none"));
console.log(" IP: " + (event.ipAddress || "unknown"));
console.log("");
});
});
Service Principal Alternatives to PATs
PATs are tied to individual users. When that user leaves the organization, the PAT stops working. Service principals are the better choice for automation.
When to Use Service Principals Instead
| Scenario | Use PAT | Use Service Principal |
|---|---|---|
| One-off script by a developer | Yes | No |
| Pipeline service connection | No | Yes |
| Long-running automation | No | Yes |
| Cross-organization access | Yes (limited) | Yes (preferred) |
| Needs to survive employee departure | No | Yes |
| Azure resource access needed | No | Yes |
Migrating from PATs to Managed Identity
For Azure-hosted pipelines, managed identity eliminates credentials entirely:
# Before: PAT stored as pipeline variable
steps:
- script: |
curl -H "Authorization: Basic $(echo -n :$(AZURE_PAT) | base64)" \
"https://dev.azure.com/org/project/_apis/build/builds?api-version=7.1"
# After: Service connection with managed identity
steps:
- task: AzureCLI@2
inputs:
azureSubscription: "ManagedIdentityConnection"
scriptType: "bash"
scriptLocation: "inlineScript"
inlineScript: |
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/project/_apis/build/builds?api-version=7.1"
Revoking Compromised Tokens
When a PAT is leaked, revoke it immediately:
// scripts/revoke-pat.js
var https = require("https");
var AZURE_AD_TOKEN = process.env.AZURE_AD_TOKEN;
var ORG = process.env.AZURE_ORG;
var PAT_ID = process.argv[2]; // Pass authorization ID as argument
if (!PAT_ID) {
console.error("Usage: node revoke-pat.js <authorization-id>");
console.error("Get IDs with: node list-pats.js");
process.exit(1);
}
var options = {
hostname: "vssps.dev.azure.com",
path: "/" + ORG + "/_apis/tokens/pats?authorizationId=" + PAT_ID + "&api-version=7.1-preview.1",
method: "DELETE",
headers: {
"Authorization": "Bearer " + AZURE_AD_TOKEN,
"Accept": "application/json"
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log("PAT revoked successfully: " + PAT_ID);
console.log("The token is no longer valid for any API calls.");
} else {
console.error("Revocation failed (" + res.statusCode + "): " + data);
}
});
});
req.on("error", function(err) { console.error("Request failed:", err.message); });
req.end();
For bulk revocation during an incident:
// scripts/revoke-all-pats.js
// WARNING: Revokes ALL PATs for the authenticated user
var https = require("https");
var AZURE_AD_TOKEN = process.env.AZURE_AD_TOKEN;
var ORG = process.env.AZURE_ORG;
function listPATs(callback) {
var options = {
hostname: "vssps.dev.azure.com",
path: "/" + ORG + "/_apis/tokens/pats?api-version=7.1-preview.1",
method: "GET",
headers: {
"Authorization": "Bearer " + AZURE_AD_TOKEN,
"Accept": "application/json"
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
callback(null, JSON.parse(data).patTokens || []);
});
});
req.on("error", callback);
req.end();
}
function revokePAT(authId, callback) {
var options = {
hostname: "vssps.dev.azure.com",
path: "/" + ORG + "/_apis/tokens/pats?authorizationId=" + authId + "&api-version=7.1-preview.1",
method: "DELETE",
headers: {
"Authorization": "Bearer " + AZURE_AD_TOKEN,
"Accept": "application/json"
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() { callback(null, res.statusCode); });
});
req.on("error", callback);
req.end();
}
listPATs(function(err, tokens) {
if (err) { console.error(err.message); process.exit(1); }
console.log("Revoking " + tokens.length + " PAT(s)...\n");
var pending = tokens.length;
if (pending === 0) {
console.log("No PATs to revoke.");
return;
}
tokens.forEach(function(t) {
revokePAT(t.authorizationId, function(err2, status) {
if (err2) {
console.error(" FAIL: " + t.displayName + " — " + err2.message);
} else {
console.log(" REVOKED: " + t.displayName + " (" + t.authorizationId + ")");
}
pending--;
if (pending === 0) {
console.log("\nAll PATs revoked.");
}
});
});
});
Complete Working Example
A full PAT lifecycle management pipeline that runs weekly:
# pat-lifecycle-pipeline.yml
trigger: none
schedules:
- cron: "0 9 * * 1"
displayName: "Weekly PAT audit and rotation"
branches:
include:
- main
always: true
pool:
vmImage: "ubuntu-latest"
variables:
- group: PAT-Management-Secrets
stages:
- stage: Audit
displayName: "Audit PAT Health"
jobs:
- job: AuditPATs
steps:
- script: node scripts/pat-monitor.js
displayName: "Check PAT expirations"
env:
AZURE_AD_TOKEN: $(AzureADToken)
AZURE_ORG: $(System.CollectionUri)
SLACK_WEBHOOK_URL: $(SlackWebhookUrl)
ALERT_THRESHOLD_DAYS: 30
- stage: Rotate
displayName: "Rotate Expiring PATs"
dependsOn: Audit
jobs:
- job: RotateBuildPAT
steps:
- task: AzureCLI@2
displayName: "Rotate build pipeline PAT"
inputs:
azureSubscription: "Azure-Production"
scriptType: "bash"
scriptLocation: "inlineScript"
inlineScript: |
node scripts/rotate-pat.js
env:
AZURE_AD_TOKEN: $(AzureADToken)
AZURE_ORG: $(System.CollectionUri)
VAULT_NAME: kv-devops-pipeline
PAT_SECRET_NAME: build-pipeline-pat
PAT_DISPLAY_NAME: automated-build-pat
PAT_SCOPES: "vso.build_execute vso.code"
PAT_VALID_DAYS: 90
- job: RotateWebhookPAT
steps:
- task: AzureCLI@2
displayName: "Rotate webhook PAT"
inputs:
azureSubscription: "Azure-Production"
scriptType: "bash"
scriptLocation: "inlineScript"
inlineScript: |
node scripts/rotate-pat.js
env:
AZURE_AD_TOKEN: $(AzureADToken)
AZURE_ORG: $(System.CollectionUri)
VAULT_NAME: kv-devops-pipeline
PAT_SECRET_NAME: webhook-pat
PAT_DISPLAY_NAME: automated-webhook-pat
PAT_SCOPES: "vso.hooks_write"
PAT_VALID_DAYS: 90
Common Issues and Troubleshooting
"TF400813: The user is not authorized to access this resource"
TF400813: The user 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' is not authorized to access this resource.
The PAT does not have the required scope for the API call. Check the API documentation for the minimum scope needed. For example, creating work items requires vso.work_write, not just vso.work. Recreate the PAT with the correct scope.
"The token has expired" after rotation
HTTP 401: {"message":"The token has expired."}
Your automation consumer (webhook, script, CI/CD system) is still using the old token. After rotating a PAT and storing it in Key Vault, update all consumers to read from Key Vault. If using variable groups linked to Key Vault, the next pipeline run picks up the new value automatically. External consumers need explicit restart or config reload.
PAT Lifecycle API returns "Access Denied" even with valid Entra ID token
HTTP 403: {"$id":"1","innerException":null,"message":"Access Denied"}
The PAT Lifecycle Management API requires an Azure AD (Entra ID) bearer token, not a PAT. You cannot manage PATs using another PAT — it creates a circular dependency. Obtain a token via az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 and use that.
"The personal access token name already exists"
HTTP 400: {"message":"A token with the name 'my-automation-pat' already exists."}
PAT display names must be unique per user. Append a timestamp or date to the name during rotation: my-automation-pat-2026-02-10. The rotation script above already handles this pattern.
Best Practices
Set maximum expiration to 90 days. Organization policies can enforce this. Go to Organization Settings > Policies > Maximum Personal Access Token Lifespan and set a cap.
Name PATs descriptively. Include the purpose, target system, and owner:
ci-build-trigger-prodpipeline-team-platform. When auditing, you need to know what a token does without looking up who created it.Never use
vso.full_accessscope. If you cannot identify the specific scopes needed, the token's purpose is not well-defined. Break it into multiple tokens with specific scopes.Store all automation PATs in Key Vault, never in pipeline variables. Pipeline variables are visible to anyone who can edit the pipeline. Key Vault provides access logging, rotation support, and centralized revocation.
Run weekly PAT audits. The monitoring script above takes seconds to run. Schedule it as a pipeline and send alerts to Slack. Catching an expiring token 30 days early is better than debugging a broken automation at 2 AM.
Prefer service principals and managed identities for long-lived automation. PATs are tied to users. When someone leaves, their PATs die. Service principals survive personnel changes and are managed at the organizational level.
Revoke PATs immediately when a team member leaves. Add PAT revocation to your offboarding checklist. Administrators can revoke any user's PATs via the admin portal.