Security

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

  1. Go to User Settings (gear icon) > Personal Access Tokens
  2. Click + New Token
  3. Set a descriptive name: ci-pipeline-build-trigger-prod
  4. Choose the Organization scope (single org is more secure than all orgs)
  5. Set expiration to 90 days maximum
  6. Select Custom defined scopes and pick only what is needed
  7. 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_access scope. 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.

References

Powered by Contentful