Security

PAT Token Management and Rotation Strategies

Manage Azure DevOps PAT lifecycle with automated rotation, Key Vault storage, expiry alerts, and security auditing

PAT Token Management and Rotation Strategies

Personal Access Tokens in Azure DevOps are the most common authentication mechanism for API access, pipeline agents, and third-party integrations — and they are also one of the most frequently mismanaged security surfaces in any organization. A single leaked PAT with full scope can grant an attacker complete control over your repositories, pipelines, and release infrastructure. This article walks through a complete approach to PAT lifecycle management: automated creation, scoped permissions, rotation before expiry, secure storage in Azure Key Vault, usage auditing, and compromise detection — all driven by Node.js.

Prerequisites

  • Node.js 16 or later installed
  • An Azure DevOps organization with administrative access
  • An Azure subscription with Key Vault provisioned
  • Basic familiarity with REST APIs and Azure DevOps concepts
  • The following npm packages: axios, @azure/identity, @azure/keyvault-secrets, node-cron, nodemailer

Install the dependencies:

npm install axios @azure/identity @azure/keyvault-secrets node-cron nodemailer

PAT Lifecycle and Expiration

Every PAT in Azure DevOps has a defined lifecycle. When you create a token, you set an expiration date — anywhere from one day to one year. After that date, the token becomes invalid and any automation depending on it breaks silently. This is a good security feature, but it creates an operational burden if you are managing dozens of tokens across teams and services.

The lifecycle stages are:

  1. Creation — A user or automated process generates a token with specific scopes and an expiration date.
  2. Active use — The token authenticates API calls, git operations, or pipeline agents.
  3. Approaching expiry — The token is still valid but nearing its expiration window.
  4. Expired — The token no longer works. Any service depending on it fails.
  5. Revoked — An administrator or the token owner explicitly invalidates the token before expiry.

The critical gap most organizations fall into is between stages 2 and 3. Without monitoring, tokens expire and break production integrations without warning.

Scope-Based PAT Creation

The single most important rule for PAT security is to never create full-scope tokens. Every PAT should have the minimum permissions required for its specific use case. Azure DevOps provides granular scopes:

Scope Use Case
vso.code Read access to repositories
vso.code_write Push to repositories
vso.build_execute Queue and manage builds
vso.release_manage Create and manage releases
vso.packaging Read feeds and packages
vso.work_write Create and update work items

Here is how you create a scoped PAT through the Azure DevOps REST API:

var axios = require("axios");

function createScopedPAT(orgName, displayName, scope, validDays, authToken) {
  var expirationDate = new Date();
  expirationDate.setDate(expirationDate.getDate() + validDays);

  var payload = {
    displayName: displayName,
    scope: scope,
    validTo: expirationDate.toISOString(),
    allOrgs: false
  };

  return axios.post(
    "https://vssps.dev.azure.com/" + orgName + "/_apis/tokens/pats?api-version=7.1-preview.1",
    payload,
    {
      headers: {
        "Authorization": "Basic " + Buffer.from(":" + authToken).toString("base64"),
        "Content-Type": "application/json"
      }
    }
  ).then(function(response) {
    return response.data.patToken;
  });
}

// Create a read-only code token valid for 30 days
createScopedPAT("myorg", "ci-code-reader", "vso.code", 30, process.env.ADMIN_PAT)
  .then(function(token) {
    console.log("Created PAT:", token.displayName);
    console.log("Expires:", token.validTo);
    // token.token contains the actual secret — store it securely
  });

Never create a PAT with app_token (full access) scope for automated processes. If a service only reads code, it gets vso.code. If it only queues builds, it gets vso.build_execute. This limits the blast radius when a token is compromised.

PAT Management REST API

Azure DevOps exposes a PAT Lifecycle Management API that lets you list, create, update, and revoke tokens programmatically. This is the foundation for any automated rotation strategy.

var axios = require("axios");

function PatApiClient(orgName, authToken) {
  this.orgName = orgName;
  this.baseUrl = "https://vssps.dev.azure.com/" + orgName + "/_apis/tokens/pats";
  this.headers = {
    "Authorization": "Basic " + Buffer.from(":" + authToken).toString("base64"),
    "Content-Type": "application/json"
  };
}

PatApiClient.prototype.listTokens = function() {
  return axios.get(this.baseUrl + "?api-version=7.1-preview.1", {
    headers: this.headers
  }).then(function(response) {
    return response.data.patTokens;
  });
};

PatApiClient.prototype.getToken = function(authorizationId) {
  return axios.get(
    this.baseUrl + "?authorizationId=" + authorizationId + "&api-version=7.1-preview.1",
    { headers: this.headers }
  ).then(function(response) {
    return response.data.patToken;
  });
};

PatApiClient.prototype.revokeToken = function(authorizationId) {
  return axios.delete(
    this.baseUrl + "?authorizationId=" + authorizationId + "&api-version=7.1-preview.1",
    { headers: this.headers }
  ).then(function(response) {
    return response.data;
  });
};

PatApiClient.prototype.createToken = function(displayName, scope, validDays) {
  var expirationDate = new Date();
  expirationDate.setDate(expirationDate.getDate() + validDays);

  var payload = {
    displayName: displayName,
    scope: scope,
    validTo: expirationDate.toISOString(),
    allOrgs: false
  };

  return axios.post(
    this.baseUrl + "?api-version=7.1-preview.1",
    payload,
    { headers: this.headers }
  ).then(function(response) {
    return response.data.patToken;
  });
};

This client wraps the four essential operations. The authorizationId is the unique identifier for each PAT — not the token value itself. You get this ID back when you list or create tokens.

Storing PATs Securely with Azure Key Vault

Tokens should never live in configuration files, environment variables on shared machines, or source code. Azure Key Vault is the right place for them.

var { DefaultAzureCredential } = require("@azure/identity");
var { SecretClient } = require("@azure/keyvault-secrets");

function KeyVaultStore(vaultUrl) {
  this.client = new SecretClient(vaultUrl, new DefaultAzureCredential());
}

KeyVaultStore.prototype.storeToken = function(name, tokenValue, expiresOn) {
  var options = {};
  if (expiresOn) {
    options.expiresOn = new Date(expiresOn);
  }
  options.contentType = "application/x-pat-token";
  options.tags = {
    "managed-by": "pat-rotation-service",
    "created": new Date().toISOString()
  };

  return this.client.setSecret(name, tokenValue, options);
};

KeyVaultStore.prototype.getToken = function(name) {
  return this.client.getSecret(name).then(function(secret) {
    return secret.value;
  });
};

KeyVaultStore.prototype.deleteToken = function(name) {
  return this.client.beginDeleteSecret(name).then(function(poller) {
    return poller.pollUntilDone();
  });
};

KeyVaultStore.prototype.listExpiringTokens = function(daysThreshold) {
  var cutoff = new Date();
  cutoff.setDate(cutoff.getDate() + daysThreshold);
  var expiring = [];
  var self = this;

  return new Promise(function(resolve, reject) {
    var iter = self.client.listPropertiesOfSecrets();
    (function next() {
      iter.next().then(function(result) {
        if (result.done) {
          resolve(expiring);
          return;
        }
        var props = result.value;
        if (props.contentType === "application/x-pat-token" && props.expiresOn) {
          if (new Date(props.expiresOn) <= cutoff) {
            expiring.push({
              name: props.name,
              expiresOn: props.expiresOn
            });
          }
        }
        next();
      }).catch(reject);
    })();
  });
};

The contentType tag lets us distinguish PAT secrets from other secrets in the vault. The listExpiringTokens method scans all secrets and returns those expiring within the given threshold. This is the query that powers our alerting system.

For local development or non-Azure environments, you can use alternative credential managers. On Windows, the Windows Credential Manager works. On Linux, libsecret or encrypted files with gpg are reasonable choices. But for production services, Key Vault is the standard.

Auditing PAT Usage

Azure DevOps provides audit logs that track PAT usage. You can query these to understand which tokens are actively used, which are dormant, and which show suspicious patterns.

function getPatAuditLogs(orgName, authToken, startDate, endDate) {
  var url = "https://auditservice.dev.azure.com/" + orgName +
    "/_apis/audit/auditlog?startTime=" + startDate.toISOString() +
    "&endTime=" + endDate.toISOString() +
    "&api-version=7.1-preview.1";

  return axios.get(url, {
    headers: {
      "Authorization": "Basic " + Buffer.from(":" + authToken).toString("base64")
    }
  }).then(function(response) {
    var entries = response.data.decoratedAuditLogEntries || [];
    return entries.filter(function(entry) {
      return entry.actionId === "Token.PatCreateEvent" ||
             entry.actionId === "Token.PatRevokeEvent" ||
             entry.actionId === "Token.PatExpiredEvent" ||
             entry.actionId === "Token.PatSystemRevokeEvent";
    });
  });
}

Look for these specific patterns that indicate compromise or misuse:

  • Tokens used from unexpected IP ranges — A PAT normally used from your CI/CD agent's IP suddenly appears from a foreign country.
  • Dormant tokens that become active — A token that has not been used in 60 days suddenly starts making API calls.
  • High-frequency API calls — A token making thousands of requests per minute is likely being used by a scraper or exfiltration tool.
  • Scope escalation attempts — API calls returning 403 errors suggest someone is testing what a stolen token can access.

Detecting Compromised Tokens

Build a detection layer that flags suspicious PAT activity:

function analyzeTokenUsage(auditEntries, knownIPs) {
  var alerts = [];

  auditEntries.forEach(function(entry) {
    // Flag usage from unknown IPs
    if (entry.ipAddress && knownIPs.indexOf(entry.ipAddress) === -1) {
      alerts.push({
        level: "high",
        message: "PAT used from unknown IP: " + entry.ipAddress,
        tokenId: entry.data ? entry.data.TokenId : "unknown",
        timestamp: entry.timestamp
      });
    }

    // Flag after-hours usage (outside 6 AM - 10 PM UTC)
    var hour = new Date(entry.timestamp).getUTCHours();
    if (hour < 6 || hour > 22) {
      alerts.push({
        level: "medium",
        message: "PAT used outside normal hours: " + hour + ":00 UTC",
        tokenId: entry.data ? entry.data.TokenId : "unknown",
        timestamp: entry.timestamp
      });
    }
  });

  return alerts;
}

When a compromise is detected, the response should be immediate and automated: revoke the token, rotate any services that depend on it, and notify the security team.

PAT vs Managed Identity vs OAuth Comparison

Not every scenario requires a PAT. Understanding when to use alternatives is part of good token management.

Factor PAT Managed Identity OAuth / Service Principal
Best for Quick integrations, personal tooling Azure-hosted services Enterprise apps, third-party tools
Rotation Manual or custom automation Automatic (Azure-managed) Certificate or secret rotation
Scope control Azure DevOps scopes only Azure RBAC Azure AD app permissions
Audit trail Azure DevOps audit logs Azure AD sign-in logs Azure AD sign-in logs
Expiration Up to 1 year No expiration (auto-rotated) Configurable
Risk High if leaked Low (never exposed) Medium

My recommendation: use Managed Identities for any service running in Azure. Use OAuth service principals for third-party tools. Reserve PATs for developer workstations and legacy integrations that do not support OAuth. If you must use PATs in CI/CD, rotate them every 30 days and scope them to the minimum permission set.

Organization-Level PAT Policies

Azure DevOps lets organization admins enforce policies on PAT creation. These are powerful guardrails that prevent developers from creating overly permissive or long-lived tokens.

Key policies to enable:

  1. Maximum token lifetime — Restrict tokens to 90 days maximum. This forces regular rotation even for manually managed tokens.
  2. Restrict full-scope tokens — Prevent users from creating tokens with app_token (full access). Every token must declare explicit scopes.
  3. Restrict creation for specific users — Service accounts that should use Managed Identity instead can be blocked from creating PATs entirely.
  4. Require Azure AD-backed users — Ensure all PAT creators are authenticated through your identity provider, not local accounts.

You can query and enforce these policies through the API:

function getOrgPolicies(orgName, authToken) {
  return axios.get(
    "https://vssps.dev.azure.com/" + orgName + "/_apis/tokenadmin/policies?api-version=7.1-preview.1",
    {
      headers: {
        "Authorization": "Basic " + Buffer.from(":" + authToken).toString("base64")
      }
    }
  ).then(function(response) {
    return response.data;
  });
}

Notifications for Expiring Tokens

Automated email alerts are non-negotiable. You cannot rely on developers to remember when their tokens expire.

var nodemailer = require("nodemailer");

function sendExpiryAlert(tokenName, expiresOn, recipientEmail, smtpConfig) {
  var transporter = nodemailer.createTransport(smtpConfig);
  var daysLeft = Math.ceil(
    (new Date(expiresOn).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
  );

  var mailOptions = {
    from: smtpConfig.auth.user,
    to: recipientEmail,
    subject: "PAT Expiring in " + daysLeft + " days: " + tokenName,
    html: "<h2>PAT Expiration Warning</h2>" +
      "<p>The Personal Access Token <strong>" + tokenName + "</strong> " +
      "will expire on <strong>" + new Date(expiresOn).toLocaleDateString() + "</strong>.</p>" +
      "<p>Days remaining: <strong>" + daysLeft + "</strong></p>" +
      "<p>Please rotate this token before it expires to avoid service disruptions.</p>" +
      "<p>If this token is managed by the automated rotation service, no action is needed.</p>"
  };

  return transporter.sendMail(mailOptions);
}

Send alerts at 30 days, 14 days, 7 days, and 1 day before expiry. Anything less and you are gambling on someone being available to act.

PAT Usage in CI/CD Pipelines

Pipelines are the most common consumer of PATs, and they deserve special attention. Here are the rules:

  1. Never store PATs as plain-text pipeline variables. Use Azure DevOps variable groups linked to Key Vault.
  2. Use separate tokens per pipeline. If one pipeline's token is compromised, you only need to rotate that one.
  3. Scope to the repository the pipeline operates on. A build pipeline for repo A should not have access to repo B.
  4. Set token expiration to match your rotation schedule. If you rotate every 30 days, set expiration to 45 days as a safety margin.
# azure-pipelines.yml example using Key Vault variable group
variables:
  - group: pat-tokens-keyvault

steps:
  - script: |
      git clone https://$(PAT_CODE_READER)@dev.azure.com/myorg/myproject/_git/myrepo
    displayName: 'Clone with scoped PAT'

The variable group pat-tokens-keyvault is linked to your Key Vault, so the pipeline pulls the current token value at runtime. When you rotate the token in Key Vault, the pipeline automatically picks up the new value on the next run.

Building a PAT Rotation Service

This is the core of the article. A rotation service handles the full lifecycle: detect tokens approaching expiry, create replacements, store them securely, update dependent services, and revoke the old tokens.

var cron = require("node-cron");

function PatRotationService(config) {
  this.patClient = new PatApiClient(config.orgName, config.adminPat);
  this.vault = new KeyVaultStore(config.vaultUrl);
  this.smtpConfig = config.smtp;
  this.alertEmail = config.alertEmail;
  this.rotationThresholdDays = config.rotationThresholdDays || 7;
  this.newTokenValidDays = config.newTokenValidDays || 30;
}

PatRotationService.prototype.checkAndRotate = function() {
  var self = this;
  console.log("[" + new Date().toISOString() + "] Starting PAT rotation check");

  return this.patClient.listTokens().then(function(tokens) {
    var now = new Date();
    var promises = [];

    tokens.forEach(function(token) {
      var expiresOn = new Date(token.validTo);
      var daysUntilExpiry = Math.ceil(
        (expiresOn.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
      );

      if (daysUntilExpiry <= self.rotationThresholdDays && daysUntilExpiry > 0) {
        console.log("Rotating token: " + token.displayName +
          " (expires in " + daysUntilExpiry + " days)");
        promises.push(self.rotateToken(token));
      } else if (daysUntilExpiry <= 0) {
        console.log("Token already expired: " + token.displayName);
      }
    });

    return Promise.all(promises);
  }).then(function(results) {
    console.log("[" + new Date().toISOString() + "] Rotation check complete. " +
      results.length + " tokens rotated.");
    return results;
  });
};

PatRotationService.prototype.rotateToken = function(oldToken) {
  var self = this;
  var newDisplayName = oldToken.displayName.replace(/-v\d+$/, "") +
    "-v" + Date.now();

  // Create new token with same scope
  return this.patClient.createToken(
    newDisplayName,
    oldToken.scope,
    self.newTokenValidDays
  ).then(function(newToken) {
    // Store new token in Key Vault
    var secretName = self.sanitizeSecretName(oldToken.displayName);
    return self.vault.storeToken(
      secretName,
      newToken.token,
      newToken.validTo
    ).then(function() {
      return newToken;
    });
  }).then(function(newToken) {
    // Revoke old token
    return self.patClient.revokeToken(oldToken.authorizationId).then(function() {
      return newToken;
    });
  }).then(function(newToken) {
    // Send notification
    return sendExpiryAlert(
      newToken.displayName,
      newToken.validTo,
      self.alertEmail,
      self.smtpConfig
    ).then(function() {
      return {
        oldToken: oldToken.displayName,
        newToken: newToken.displayName,
        expiresOn: newToken.validTo
      };
    });
  });
};

PatRotationService.prototype.sanitizeSecretName = function(name) {
  // Key Vault secret names can only contain alphanumeric and dashes
  return name.replace(/[^a-zA-Z0-9-]/g, "-").substring(0, 127);
};

PatRotationService.prototype.revokeCompromised = function(authorizationId, reason) {
  var self = this;
  console.log("SECURITY: Revoking compromised token " + authorizationId +
    ". Reason: " + reason);

  return this.patClient.revokeToken(authorizationId).then(function() {
    return sendExpiryAlert(
      "COMPROMISED TOKEN REVOKED: " + authorizationId,
      new Date().toISOString(),
      self.alertEmail,
      self.smtpConfig
    );
  });
};

Complete Working Example

Here is the full service wired together with a cron schedule, health check endpoint, and CLI interface:

var http = require("http");
var cron = require("node-cron");
var axios = require("axios");
var { DefaultAzureCredential } = require("@azure/identity");
var { SecretClient } = require("@azure/keyvault-secrets");
var nodemailer = require("nodemailer");

// ---- Configuration ----
var config = {
  orgName: process.env.AZURE_DEVOPS_ORG,
  adminPat: process.env.AZURE_DEVOPS_ADMIN_PAT,
  vaultUrl: process.env.KEY_VAULT_URL,
  alertEmail: process.env.ALERT_EMAIL,
  rotationThresholdDays: parseInt(process.env.ROTATION_THRESHOLD_DAYS || "7", 10),
  newTokenValidDays: parseInt(process.env.NEW_TOKEN_VALID_DAYS || "30", 10),
  smtp: {
    host: process.env.SMTP_HOST,
    port: parseInt(process.env.SMTP_PORT || "587", 10),
    secure: false,
    auth: {
      user: process.env.SMTP_USER,
      pass: process.env.SMTP_PASS
    }
  }
};

// ---- PAT API Client ----
function PatApiClient(orgName, authToken) {
  this.orgName = orgName;
  this.baseUrl = "https://vssps.dev.azure.com/" + orgName + "/_apis/tokens/pats";
  this.headers = {
    "Authorization": "Basic " + Buffer.from(":" + authToken).toString("base64"),
    "Content-Type": "application/json"
  };
}

PatApiClient.prototype.listTokens = function() {
  return axios.get(this.baseUrl + "?api-version=7.1-preview.1", {
    headers: this.headers
  }).then(function(res) { return res.data.patTokens || []; });
};

PatApiClient.prototype.createToken = function(displayName, scope, validDays) {
  var expirationDate = new Date();
  expirationDate.setDate(expirationDate.getDate() + validDays);
  return axios.post(this.baseUrl + "?api-version=7.1-preview.1", {
    displayName: displayName,
    scope: scope,
    validTo: expirationDate.toISOString(),
    allOrgs: false
  }, { headers: this.headers }).then(function(res) { return res.data.patToken; });
};

PatApiClient.prototype.revokeToken = function(authorizationId) {
  return axios.delete(
    this.baseUrl + "?authorizationId=" + authorizationId + "&api-version=7.1-preview.1",
    { headers: this.headers }
  );
};

// ---- Key Vault Store ----
function KeyVaultStore(vaultUrl) {
  this.client = new SecretClient(vaultUrl, new DefaultAzureCredential());
}

KeyVaultStore.prototype.storeToken = function(name, value, expiresOn) {
  return this.client.setSecret(name, value, {
    expiresOn: new Date(expiresOn),
    contentType: "application/x-pat-token",
    tags: { "managed-by": "pat-rotation-service", "created": new Date().toISOString() }
  });
};

KeyVaultStore.prototype.getToken = function(name) {
  return this.client.getSecret(name).then(function(s) { return s.value; });
};

KeyVaultStore.prototype.listExpiringTokens = function(daysThreshold) {
  var cutoff = new Date();
  cutoff.setDate(cutoff.getDate() + daysThreshold);
  var expiring = [];
  var self = this;
  return new Promise(function(resolve, reject) {
    var iter = self.client.listPropertiesOfSecrets();
    (function next() {
      iter.next().then(function(result) {
        if (result.done) return resolve(expiring);
        var p = result.value;
        if (p.contentType === "application/x-pat-token" && p.expiresOn && new Date(p.expiresOn) <= cutoff) {
          expiring.push({ name: p.name, expiresOn: p.expiresOn });
        }
        next();
      }).catch(reject);
    })();
  });
};

// ---- Email Alerts ----
function sendAlert(subject, body, alertConfig) {
  var transporter = nodemailer.createTransport(alertConfig.smtp);
  return transporter.sendMail({
    from: alertConfig.smtp.auth.user,
    to: alertConfig.alertEmail,
    subject: subject,
    html: body
  });
}

// ---- Rotation Service ----
function PatRotationService(cfg) {
  this.patClient = new PatApiClient(cfg.orgName, cfg.adminPat);
  this.vault = new KeyVaultStore(cfg.vaultUrl);
  this.config = cfg;
  this.lastRunStatus = { timestamp: null, rotated: 0, errors: [] };
}

PatRotationService.prototype.sanitizeName = function(name) {
  return name.replace(/[^a-zA-Z0-9-]/g, "-").substring(0, 127);
};

PatRotationService.prototype.run = function() {
  var self = this;
  var startTime = new Date();
  console.log("[" + startTime.toISOString() + "] PAT rotation check started");

  return this.patClient.listTokens().then(function(tokens) {
    var now = Date.now();
    var rotationTasks = [];

    tokens.forEach(function(token) {
      var daysLeft = Math.ceil(
        (new Date(token.validTo).getTime() - now) / (1000 * 60 * 60 * 24)
      );

      if (daysLeft <= 0) {
        console.log("  EXPIRED: " + token.displayName);
        return;
      }

      if (daysLeft <= self.config.rotationThresholdDays) {
        console.log("  ROTATING: " + token.displayName + " (" + daysLeft + " days left)");
        rotationTasks.push(self.rotateOne(token));
      } else if (daysLeft <= 14) {
        console.log("  WARNING: " + token.displayName + " (" + daysLeft + " days left)");
        rotationTasks.push(
          sendAlert(
            "PAT Expiring Soon: " + token.displayName,
            "<p>Token <strong>" + token.displayName + "</strong> expires in " +
              daysLeft + " days on " + new Date(token.validTo).toLocaleDateString() + ".</p>",
            self.config
          ).catch(function(err) {
            console.error("  Failed to send warning email:", err.message);
          })
        );
      }
    });

    return Promise.all(rotationTasks);
  }).then(function(results) {
    var rotated = results.filter(function(r) { return r && r.newToken; });
    self.lastRunStatus = {
      timestamp: startTime.toISOString(),
      rotated: rotated.length,
      errors: []
    };
    console.log("[" + new Date().toISOString() + "] Done. Rotated " + rotated.length + " tokens.");
    return self.lastRunStatus;
  }).catch(function(err) {
    self.lastRunStatus = {
      timestamp: startTime.toISOString(),
      rotated: 0,
      errors: [err.message]
    };
    console.error("Rotation check failed:", err.message);
    return self.lastRunStatus;
  });
};

PatRotationService.prototype.rotateOne = function(oldToken) {
  var self = this;
  var versionSuffix = "-v" + Date.now();
  var baseName = oldToken.displayName.replace(/-v\d+$/, "");
  var newName = baseName + versionSuffix;

  return this.patClient.createToken(
    newName,
    oldToken.scope,
    self.config.newTokenValidDays
  ).then(function(newToken) {
    var secretName = self.sanitizeName(baseName);
    return self.vault.storeToken(secretName, newToken.token, newToken.validTo)
      .then(function() { return newToken; });
  }).then(function(newToken) {
    return self.patClient.revokeToken(oldToken.authorizationId)
      .then(function() { return newToken; });
  }).then(function(newToken) {
    return sendAlert(
      "PAT Rotated: " + baseName,
      "<h3>Token Rotated Successfully</h3>" +
        "<p><strong>Old:</strong> " + oldToken.displayName + "</p>" +
        "<p><strong>New:</strong> " + newToken.displayName + "</p>" +
        "<p><strong>Expires:</strong> " + new Date(newToken.validTo).toLocaleDateString() + "</p>" +
        "<p>The new token has been stored in Key Vault as <code>" +
        self.sanitizeName(baseName) + "</code>.</p>",
      self.config
    ).then(function() {
      return { oldToken: oldToken.displayName, newToken: newToken.displayName };
    });
  }).catch(function(err) {
    console.error("  Failed to rotate " + oldToken.displayName + ":", err.message);
    return null;
  });
};

PatRotationService.prototype.revokeCompromised = function(authorizationId, reason) {
  var self = this;
  console.log("SECURITY ALERT: Revoking token " + authorizationId + " — " + reason);
  return this.patClient.revokeToken(authorizationId).then(function() {
    return sendAlert(
      "SECURITY: Compromised PAT Revoked",
      "<h2 style='color:red'>Compromised Token Revoked</h2>" +
        "<p><strong>Authorization ID:</strong> " + authorizationId + "</p>" +
        "<p><strong>Reason:</strong> " + reason + "</p>" +
        "<p><strong>Time:</strong> " + new Date().toISOString() + "</p>" +
        "<p>Investigate immediately. Check audit logs for unauthorized access.</p>",
      self.config
    );
  });
};

// ---- Health Check HTTP Server ----
var service = new PatRotationService(config);

var server = http.createServer(function(req, res) {
  if (req.url === "/health") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({
      status: "ok",
      lastRun: service.lastRunStatus,
      config: {
        org: config.orgName,
        rotationThreshold: config.rotationThresholdDays,
        tokenValidity: config.newTokenValidDays
      }
    }));
  } else if (req.url === "/rotate" && req.method === "POST") {
    service.run().then(function(result) {
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify(result));
    }).catch(function(err) {
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: err.message }));
    });
  } else {
    res.writeHead(404);
    res.end("Not found");
  }
});

var PORT = parseInt(process.env.PORT || "3000", 10);
server.listen(PORT, function() {
  console.log("PAT Rotation Service running on port " + PORT);
});

// ---- Cron Schedule: Run daily at 8 AM UTC ----
cron.schedule("0 8 * * *", function() {
  console.log("Cron triggered: running PAT rotation check");
  service.run();
});

// ---- Run immediately on startup ----
service.run();

Set the environment variables and start the service:

export AZURE_DEVOPS_ORG="myorg"
export AZURE_DEVOPS_ADMIN_PAT="your-admin-pat-here"
export KEY_VAULT_URL="https://mykeyvault.vault.azure.net"
export ALERT_EMAIL="[email protected]"
export ROTATION_THRESHOLD_DAYS="7"
export NEW_TOKEN_VALID_DAYS="30"
export SMTP_HOST="smtp.office365.com"
export SMTP_PORT="587"
export SMTP_USER="[email protected]"
export SMTP_PASS="smtp-password"

node pat-rotation-service.js

The service exposes two endpoints:

  • GET /health — Returns the last run status and configuration.
  • POST /rotate — Triggers an immediate rotation check.

The cron job runs daily at 8 AM UTC. On startup, it also runs an immediate check so you get immediate feedback when deploying.

Common Issues and Troubleshooting

1. "TF400813: The user is not authorized to access this resource"

This happens when the admin PAT used by the rotation service does not have the vso.tokens scope, or the user who created the admin PAT is not an organization administrator. The PAT Lifecycle Management API requires the caller to have org-level admin permissions.

Fix: Create the admin PAT with Token Administration scope and ensure the user is in the Project Collection Administrators group.

2. Token created but Key Vault storage fails

If the PAT is created successfully but the Key Vault write fails, you end up with an active token that is not tracked. This is a dangerous state because the token exists but your rotation service does not know about it.

Fix: Implement a two-phase approach. Create the token, store it in Key Vault, and only then revoke the old one. If the Key Vault write fails, revoke the newly created token immediately and alert the team. Add retry logic with exponential backoff for transient Key Vault failures.

3. Rate limiting on PAT API calls

The Azure DevOps PAT API has rate limits. If you are managing hundreds of tokens, you may hit the limit during a rotation cycle.

Fix: Add delays between API calls. Process tokens in batches of 10 with a 2-second pause between batches. Also, cache the token list and only call the API once per rotation cycle rather than per-token.

4. Secret name collisions in Key Vault

Key Vault secret names must be unique and can only contain alphanumeric characters and dashes. If two PATs have display names that sanitize to the same Key Vault secret name, one will overwrite the other.

Fix: Include a short hash of the original display name in the sanitized secret name. For example, append the first 8 characters of a SHA-256 hash of the original name.

5. Rotation runs but dependent services still use old token

Storing the new token in Key Vault does not automatically update running services. If a service caches the token in memory, it will keep using the old (now revoked) token until it restarts.

Fix: Services consuming PATs from Key Vault should implement a polling mechanism that checks for secret version changes every few minutes. Alternatively, use Key Vault event triggers with Azure Event Grid to notify services of secret updates.

Best Practices

  • Set maximum PAT lifetime to 30 days. Shorter lifetimes reduce the window of exposure if a token is compromised. For truly sensitive operations, consider 7-day tokens with automated rotation.

  • One token per service, per purpose. Never share a PAT across multiple services. If service A and service B both need code read access, create two separate tokens. This makes revocation surgical instead of disruptive.

  • Automate everything. Manual token management does not scale. If you have more than five PATs in your organization, you need an automated rotation service. The time investment pays for itself after the first prevented outage.

  • Monitor for unused tokens. Any PAT that has not been used in 30 days should be revoked. Dormant tokens are free attack surface with zero operational value.

  • Use Managed Identities where possible. If your service runs on Azure (App Service, AKS, VMs, Functions), prefer Managed Identities over PATs. They eliminate the rotation problem entirely because Azure handles credential management automatically.

  • Encrypt PATs at rest and in transit. Never log token values. Never store them in plain-text configuration files. Never pass them as command-line arguments (they show up in process listings). Key Vault, environment variables in secure compute contexts, or CI/CD secret stores are the only acceptable locations.

  • Implement break-glass procedures. Have a documented, tested process for revoking all PATs in the organization within minutes. If you detect a broad compromise, you need to be able to shut everything down fast and then selectively restore access.

  • Audit PAT creation and usage weekly. Set up a recurring report that shows all PATs created in the last week, their scopes, expiration dates, and usage patterns. Review it as part of your security standup.

  • Enforce organization policies. Do not rely on developers to follow best practices voluntarily. Use Azure DevOps organization settings to restrict maximum token lifetime, prohibit full-scope tokens, and require justification for new tokens.

References

Powered by Contentful