Azure DevOps OAuth Apps and Personal Access Tokens
Complete guide to building OAuth 2.0 applications and managing Personal Access Tokens for Azure DevOps, including token lifecycle management, scope configuration, and secure automation patterns.
Azure DevOps OAuth Apps and Personal Access Tokens
Overview
Authentication is the gateway to every Azure DevOps automation you will ever build. Whether you are writing a pipeline extension, building an internal dashboard, or scripting bulk operations against the REST API, you need to choose between OAuth 2.0 apps and Personal Access Tokens — and that choice has real consequences for security, maintainability, and user experience. I have built production integrations using both approaches, and the right answer depends entirely on who is authenticating and how long the integration needs to live.
Prerequisites
- An Azure DevOps organization with admin access for registering OAuth apps
- Node.js 16 or later installed locally
- An Azure Active Directory tenant (for AAD-backed OAuth flows)
- Basic understanding of HTTP authentication headers and REST APIs
- A registered application in Azure DevOps (for the OAuth sections)
Understanding the Authentication Landscape
Azure DevOps supports several authentication mechanisms. The two primary ones for programmatic access are OAuth 2.0 and Personal Access Tokens. There is also basic auth (deprecated), SSH keys (for Git only), and managed identities (Azure-hosted workloads only). Before diving into implementation, you need to understand when each option applies.
Personal Access Tokens
PATs are the simplest authentication mechanism. They are essentially long-lived bearer tokens tied to a specific user identity. When your automation uses a PAT, it acts as that user — with whatever permissions that user has, filtered by the scopes you assign to the token.
PATs work well for:
- CI/CD pipelines where a service account needs API access
- Personal scripts and local development tools
- Quick prototypes and one-off migrations
- Scenarios where you control both the client and the server
PATs are problematic for:
- Multi-tenant applications where different users authenticate
- Applications distributed to external users
- Scenarios requiring granular consent flows
- Long-lived integrations where token rotation is hard to automate
OAuth 2.0 Applications
OAuth apps let users authenticate through a browser-based consent flow. The application receives a short-lived access token and a refresh token. This is the correct choice when you are building a tool that multiple people will use with their own identities.
OAuth works well for:
- Web applications where users sign in
- Third-party integrations distributed to multiple organizations
- Scenarios requiring delegated permissions with user consent
- Applications that need to act on behalf of different users
Creating and Managing Personal Access Tokens
Creating PATs Programmatically
While most people create PATs through the Azure DevOps UI, you can manage them programmatically through the Token Administration API. Here is a Node.js utility for PAT lifecycle management:
var https = require("https");
var url = require("url");
function PatManager(organizationUrl, adminPat) {
this.orgUrl = organizationUrl;
this.adminPat = adminPat;
this.apiBase = "https://vssps.dev.azure.com";
}
PatManager.prototype.createPat = function(displayName, scope, validTo, callback) {
var orgName = this.orgUrl.split("/").pop();
var requestBody = JSON.stringify({
displayName: displayName,
scope: scope,
validTo: validTo,
allOrgs: false
});
var options = {
hostname: "vssps.dev.azure.com",
path: "/" + orgName + "/_apis/tokens/pats?api-version=7.1-preview.1",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(requestBody),
"Authorization": "Basic " + Buffer.from(":" + this.adminPat).toString("base64")
}
};
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.patToken);
} else {
callback(new Error("PAT creation failed: " + res.statusCode + " - " + data));
}
});
});
req.on("error", callback);
req.write(requestBody);
req.end();
};
PatManager.prototype.listPats = function(callback) {
var orgName = this.orgUrl.split("/").pop();
var options = {
hostname: "vssps.dev.azure.com",
path: "/" + orgName + "/_apis/tokens/pats?api-version=7.1-preview.1",
method: "GET",
headers: {
"Authorization": "Basic " + Buffer.from(":" + this.adminPat).toString("base64")
}
};
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 + " - " + data));
}
});
});
req.on("error", callback);
req.end();
};
PatManager.prototype.revokePat = function(authorizationId, callback) {
var orgName = this.orgUrl.split("/").pop();
var options = {
hostname: "vssps.dev.azure.com",
path: "/" + orgName + "/_apis/tokens/pats?authorizationId=" + authorizationId + "&api-version=7.1-preview.1",
method: "DELETE",
headers: {
"Authorization": "Basic " + Buffer.from(":" + this.adminPat).toString("base64")
}
};
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 === 204) {
callback(null, true);
} else {
callback(new Error("Revoke failed: " + res.statusCode + " - " + data));
}
});
});
req.on("error", callback);
req.end();
};
PAT Scope Reference
Scopes control what a PAT can access. Always use the minimum scopes required:
| Scope | Permission | Use Case |
|---|---|---|
vso.code |
Code (read) | Reading repositories |
vso.code_write |
Code (read/write) | Pushing commits |
vso.code_manage |
Code (read/write/manage) | Branch policies, creating repos |
vso.build_execute |
Build (read/execute) | Triggering pipelines |
vso.packaging |
Packaging (read) | Downloading packages |
vso.packaging_write |
Packaging (read/write) | Publishing packages |
vso.work |
Work items (read) | Reading boards and items |
vso.work_write |
Work items (read/write) | Creating/updating work items |
A common mistake is granting vso.code_manage when vso.code is sufficient. I have seen PATs with full access used for read-only dashboards — that is a security incident waiting to happen.
PAT Rotation Strategy
PATs expire, and when they do, your automations break at the worst possible time. Here is an automated rotation system:
var fs = require("fs");
var path = require("path");
function PatRotator(config) {
this.manager = new PatManager(config.organizationUrl, config.adminPat);
this.configPath = config.configPath || path.join(__dirname, "pat-config.json");
this.rotationDays = config.rotationDays || 30;
this.warningDays = config.warningDays || 7;
}
PatRotator.prototype.checkExpiration = function(callback) {
var self = this;
this.manager.listPats(function(err, tokens) {
if (err) return callback(err);
var now = new Date();
var warnings = [];
var expired = [];
tokens.forEach(function(token) {
var expiresAt = new Date(token.validTo);
var daysRemaining = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
if (daysRemaining <= 0) {
expired.push({
name: token.displayName,
expired: expiresAt.toISOString(),
authorizationId: token.authorizationId
});
} else if (daysRemaining <= self.warningDays) {
warnings.push({
name: token.displayName,
expires: expiresAt.toISOString(),
daysRemaining: daysRemaining,
authorizationId: token.authorizationId
});
}
});
callback(null, { warnings: warnings, expired: expired });
});
};
PatRotator.prototype.rotateToken = function(displayName, scope, callback) {
var self = this;
var validTo = new Date();
validTo.setDate(validTo.getDate() + this.rotationDays);
this.manager.listPats(function(err, tokens) {
if (err) return callback(err);
var existing = tokens.filter(function(t) {
return t.displayName === displayName;
});
self.manager.createPat(displayName + "-rotated", scope, validTo.toISOString(), function(err, newToken) {
if (err) return callback(err);
console.log("New token created: " + displayName + "-rotated");
console.log("Expires: " + validTo.toISOString());
var revokeCount = 0;
if (existing.length === 0) {
return callback(null, { newToken: newToken, revokedCount: 0 });
}
existing.forEach(function(oldToken) {
self.manager.revokePat(oldToken.authorizationId, function(revokeErr) {
revokeCount++;
if (revokeErr) {
console.error("Failed to revoke old token: " + oldToken.authorizationId);
}
if (revokeCount === existing.length) {
callback(null, { newToken: newToken, revokedCount: revokeCount });
}
});
});
});
});
};
Building an OAuth 2.0 Application
Registering Your Application
Before writing code, register your app at https://app.vsaex.visualstudio.com/app/register. You need:
- Company name and Application name
- Application website — your app's homepage
- Authorization callback URL — where Azure DevOps redirects after consent (e.g.,
https://yourapp.com/auth/callback) - Authorized scopes — the permissions your app requests
After registration, you receive an App ID and Client Secret. Store the client secret securely — you cannot retrieve it later.
OAuth 2.0 Authorization Flow
The Azure DevOps OAuth flow follows a standard authorization code pattern with a few quirks. Here is a complete Express.js implementation:
var express = require("express");
var https = require("https");
var querystring = require("querystring");
var crypto = require("crypto");
var app = express();
var config = {
clientId: process.env.AZURE_DEVOPS_APP_ID,
clientSecret: process.env.AZURE_DEVOPS_CLIENT_SECRET,
callbackUrl: process.env.CALLBACK_URL || "http://localhost:3000/auth/callback",
scope: "vso.code vso.work vso.build_execute",
authorizeUrl: "https://app.vssps.visualstudio.com/oauth2/authorize",
tokenUrl: "https://app.vssps.visualstudio.com/oauth2/token"
};
var sessions = {};
app.get("/auth/login", function(req, res) {
var state = crypto.randomBytes(16).toString("hex");
sessions[state] = {
created: Date.now(),
ip: req.ip
};
var params = querystring.stringify({
client_id: config.clientId,
response_type: "Assertion",
state: state,
scope: config.scope,
redirect_uri: config.callbackUrl
});
res.redirect(config.authorizeUrl + "?" + params);
});
app.get("/auth/callback", function(req, res) {
var code = req.query.code;
var state = req.query.state;
if (!sessions[state]) {
return res.status(400).send("Invalid state parameter — possible CSRF attack");
}
delete sessions[state];
var postData = querystring.stringify({
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: config.clientSecret,
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: code,
redirect_uri: config.callbackUrl
});
var parsed = require("url").parse(config.tokenUrl);
var options = {
hostname: parsed.hostname,
path: parsed.path,
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(postData)
}
};
var tokenReq = https.request(options, function(tokenRes) {
var data = "";
tokenRes.on("data", function(chunk) { data += chunk; });
tokenRes.on("end", function() {
if (tokenRes.statusCode === 200) {
var tokens = JSON.parse(data);
console.log("Access token received, expires in " + tokens.expires_in + " seconds");
res.json({
message: "Authentication successful",
expiresIn: tokens.expires_in,
tokenType: tokens.token_type
});
} else {
console.error("Token exchange failed: " + data);
res.status(500).send("Authentication failed: " + data);
}
});
});
tokenReq.on("error", function(err) {
console.error("Token request error: " + err.message);
res.status(500).send("Token exchange failed");
});
tokenReq.write(postData);
tokenReq.end();
});
Notice the Azure DevOps quirks: the response_type is Assertion (not the standard code), and the token exchange uses client_assertion instead of client_secret. These non-standard parameters trip up every developer the first time.
Token Refresh and Storage
Access tokens expire after one hour. You must use the refresh token to get new ones. Here is a token manager that handles automatic refresh:
function TokenStore(storePath) {
this.storePath = storePath || path.join(__dirname, ".token-store.json");
this.tokens = {};
this._load();
}
TokenStore.prototype._load = function() {
try {
var data = fs.readFileSync(this.storePath, "utf8");
this.tokens = JSON.parse(data);
} catch (e) {
this.tokens = {};
}
};
TokenStore.prototype._save = function() {
fs.writeFileSync(this.storePath, JSON.stringify(this.tokens, null, 2));
};
TokenStore.prototype.getValidToken = function(userId, callback) {
var self = this;
var entry = this.tokens[userId];
if (!entry) {
return callback(new Error("No tokens stored for user: " + userId));
}
var expiresAt = new Date(entry.expiresAt);
var bufferMs = 5 * 60 * 1000;
if (expiresAt.getTime() - bufferMs > Date.now()) {
return callback(null, entry.accessToken);
}
console.log("Token expired for user " + userId + ", refreshing...");
this._refreshToken(entry.refreshToken, function(err, newTokens) {
if (err) return callback(err);
self.tokens[userId] = {
accessToken: newTokens.access_token,
refreshToken: newTokens.refresh_token,
expiresAt: new Date(Date.now() + (newTokens.expires_in * 1000)).toISOString()
};
self._save();
callback(null, newTokens.access_token);
});
};
TokenStore.prototype._refreshToken = function(refreshToken, callback) {
var postData = querystring.stringify({
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: config.clientSecret,
grant_type: "refresh_token",
assertion: refreshToken,
redirect_uri: config.callbackUrl
});
var parsed = require("url").parse(config.tokenUrl);
var options = {
hostname: parsed.hostname,
path: parsed.path,
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(postData)
}
};
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("Token refresh failed: " + res.statusCode + " - " + data));
}
});
});
req.on("error", callback);
req.write(postData);
req.end();
};
Complete Working Example: PAT and OAuth Management Dashboard
This is a complete Express.js application that serves as both a PAT management console and an OAuth-enabled Azure DevOps dashboard. It lists your PATs, checks for expiring tokens, and provides an OAuth login flow for delegated access.
var express = require("express");
var https = require("https");
var querystring = require("querystring");
var crypto = require("crypto");
var fs = require("fs");
var path = require("path");
var app = express();
app.use(express.json());
var PORT = process.env.PORT || 3000;
var oauthConfig = {
clientId: process.env.AZURE_DEVOPS_APP_ID,
clientSecret: process.env.AZURE_DEVOPS_CLIENT_SECRET,
callbackUrl: process.env.CALLBACK_URL || "http://localhost:" + PORT + "/auth/callback",
scope: "vso.code vso.work vso.build_execute vso.packaging",
authorizeUrl: "https://app.vssps.visualstudio.com/oauth2/authorize",
tokenUrl: "https://app.vssps.visualstudio.com/oauth2/token"
};
var orgUrl = process.env.AZURE_DEVOPS_ORG_URL;
var adminPat = process.env.AZURE_DEVOPS_PAT;
function apiRequest(method, hostname, requestPath, token, body, callback) {
var bodyStr = body ? JSON.stringify(body) : "";
var options = {
hostname: hostname,
path: requestPath,
method: method,
headers: {
"Content-Type": "application/json",
"Authorization": "Basic " + Buffer.from(":" + token).toString("base64")
}
};
if (body) {
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(method + " " + requestPath + " failed: " + res.statusCode + " - " + data));
}
});
});
req.on("error", callback);
if (body) req.write(bodyStr);
req.end();
}
// PAT Management Endpoints
app.get("/api/pats", function(req, res) {
var orgName = orgUrl.split("/").pop();
apiRequest(
"GET",
"vssps.dev.azure.com",
"/" + orgName + "/_apis/tokens/pats?api-version=7.1-preview.1",
adminPat,
null,
function(err, result) {
if (err) return res.status(500).json({ error: err.message });
var tokens = (result.patTokens || []).map(function(t) {
var expiresAt = new Date(t.validTo);
var daysRemaining = Math.ceil((expiresAt - new Date()) / (1000 * 60 * 60 * 24));
return {
displayName: t.displayName,
scope: t.scope,
validFrom: t.validFrom,
validTo: t.validTo,
daysRemaining: daysRemaining,
status: daysRemaining <= 0 ? "expired" : daysRemaining <= 7 ? "warning" : "active",
authorizationId: t.authorizationId
};
});
tokens.sort(function(a, b) { return a.daysRemaining - b.daysRemaining; });
res.json({ tokens: tokens, total: tokens.length });
}
);
});
app.post("/api/pats", function(req, res) {
var orgName = orgUrl.split("/").pop();
var validTo = new Date();
validTo.setDate(validTo.getDate() + (req.body.validDays || 30));
var body = {
displayName: req.body.displayName,
scope: req.body.scope || "vso.code vso.work",
validTo: validTo.toISOString(),
allOrgs: false
};
apiRequest(
"POST",
"vssps.dev.azure.com",
"/" + orgName + "/_apis/tokens/pats?api-version=7.1-preview.1",
adminPat,
body,
function(err, result) {
if (err) return res.status(500).json({ error: err.message });
res.json({
displayName: body.displayName,
token: result.patToken.token,
validTo: body.validTo,
message: "Store this token securely — it cannot be retrieved again"
});
}
);
});
app.delete("/api/pats/:authorizationId", function(req, res) {
var orgName = orgUrl.split("/").pop();
apiRequest(
"DELETE",
"vssps.dev.azure.com",
"/" + orgName + "/_apis/tokens/pats?authorizationId=" + req.params.authorizationId + "&api-version=7.1-preview.1",
adminPat,
null,
function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: "Token revoked successfully" });
}
);
});
app.get("/api/pats/health", function(req, res) {
var orgName = orgUrl.split("/").pop();
apiRequest(
"GET",
"vssps.dev.azure.com",
"/" + orgName + "/_apis/tokens/pats?api-version=7.1-preview.1",
adminPat,
null,
function(err, result) {
if (err) return res.status(500).json({ error: err.message });
var tokens = result.patTokens || [];
var now = new Date();
var expired = 0;
var expiringSoon = 0;
var active = 0;
tokens.forEach(function(t) {
var daysLeft = Math.ceil((new Date(t.validTo) - now) / (1000 * 60 * 60 * 24));
if (daysLeft <= 0) expired++;
else if (daysLeft <= 7) expiringSoon++;
else active++;
});
res.json({
total: tokens.length,
active: active,
expiringSoon: expiringSoon,
expired: expired,
healthScore: tokens.length > 0
? Math.round((active / tokens.length) * 100)
: 100
});
}
);
});
// OAuth Endpoints
var pendingStates = {};
app.get("/auth/login", function(req, res) {
var state = crypto.randomBytes(16).toString("hex");
pendingStates[state] = { created: Date.now(), ip: req.ip };
setTimeout(function() { delete pendingStates[state]; }, 10 * 60 * 1000);
var params = querystring.stringify({
client_id: oauthConfig.clientId,
response_type: "Assertion",
state: state,
scope: oauthConfig.scope,
redirect_uri: oauthConfig.callbackUrl
});
res.redirect(oauthConfig.authorizeUrl + "?" + params);
});
app.get("/auth/callback", function(req, res) {
var code = req.query.code;
var state = req.query.state;
if (!pendingStates[state]) {
return res.status(400).json({ error: "Invalid or expired state parameter" });
}
delete pendingStates[state];
var postData = querystring.stringify({
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: oauthConfig.clientSecret,
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: code,
redirect_uri: oauthConfig.callbackUrl
});
var parsed = require("url").parse(oauthConfig.tokenUrl);
var options = {
hostname: parsed.hostname,
path: parsed.path,
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(postData)
}
};
var tokenReq = https.request(options, function(tokenRes) {
var data = "";
tokenRes.on("data", function(chunk) { data += chunk; });
tokenRes.on("end", function() {
if (tokenRes.statusCode === 200) {
var tokens = JSON.parse(data);
res.json({
message: "OAuth authentication successful",
tokenType: tokens.token_type,
expiresIn: tokens.expires_in,
scope: tokens.scope
});
} else {
res.status(500).json({ error: "Token exchange failed", details: data });
}
});
});
tokenReq.on("error", function(err) {
res.status(500).json({ error: "Token exchange request failed: " + err.message });
});
tokenReq.write(postData);
tokenReq.end();
});
app.listen(PORT, function() {
console.log("Azure DevOps Auth Manager running on port " + PORT);
console.log("PAT endpoints: GET/POST/DELETE /api/pats");
console.log("PAT health check: GET /api/pats/health");
console.log("OAuth login: GET /auth/login");
});
Test the PAT endpoints:
# List all PATs
curl http://localhost:3000/api/pats
# Check PAT health
curl http://localhost:3000/api/pats/health
# Create a new PAT
curl -X POST http://localhost:3000/api/pats \
-H "Content-Type: application/json" \
-d '{"displayName": "pipeline-deploy", "scope": "vso.build_execute vso.code", "validDays": 90}'
# Revoke a PAT
curl -X DELETE http://localhost:3000/api/pats/abc123-auth-id-here
# Start OAuth flow (open in browser)
# http://localhost:3000/auth/login
Expected output from the health endpoint:
{
"total": 12,
"active": 9,
"expiringSoon": 2,
"expired": 1,
"healthScore": 75
}
Choosing Between OAuth and PATs
The decision matrix I use in production:
| Factor | PAT | OAuth |
|---|---|---|
| Setup complexity | Minutes | Hours |
| User interaction needed | None | Browser consent |
| Multi-user support | No (single identity) | Yes |
| Token lifetime | Up to 1 year | 1 hour (refreshable) |
| Revocation | Manual or API | Per-user consent revocation |
| Audit trail | Tied to one user | Per-user activity |
| Pipeline use | Excellent | Awkward (no browser) |
| Scope granularity | Per-token | Per-app registration |
For internal tooling, I almost always start with PATs and migrate to OAuth only when the tool needs to serve multiple users or when security requirements demand short-lived tokens.
Securing Token Storage
Never store tokens in plain text. Here is a simple encryption wrapper using Node.js built-in crypto:
var crypto = require("crypto");
function TokenVault(encryptionKey) {
this.algorithm = "aes-256-gcm";
this.key = crypto.scryptSync(encryptionKey, "azure-devops-vault", 32);
}
TokenVault.prototype.encrypt = function(plaintext) {
var iv = crypto.randomBytes(16);
var cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
var encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
var tag = cipher.getAuthTag();
return {
iv: iv.toString("hex"),
data: encrypted,
tag: tag.toString("hex")
};
};
TokenVault.prototype.decrypt = function(encrypted) {
var iv = Buffer.from(encrypted.iv, "hex");
var tag = Buffer.from(encrypted.tag, "hex");
var decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
decipher.setAuthTag(tag);
var decrypted = decipher.update(encrypted.data, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
};
// Usage
var vault = new TokenVault(process.env.VAULT_KEY || "change-this-in-production");
var encrypted = vault.encrypt("my-secret-pat-token-value");
console.log("Encrypted:", JSON.stringify(encrypted));
var decrypted = vault.decrypt(encrypted);
console.log("Decrypted:", decrypted);
Common Issues and Troubleshooting
"TF400813: The user is not authorized to access this resource"
This appears when a PAT lacks the required scope. Check the exact API endpoint you are calling against the scope table above. The error does not tell you which scope is missing — you have to figure it out from the API documentation.
# Verify your PAT works at all with a basic profile request
curl -u :YOUR_PAT https://dev.azure.com/YOUR_ORG/_apis/projects?api-version=7.1
# If this fails too, the PAT itself is invalid or expired
OAuth callback returns "redirect_uri does not match"
The callback URL in your authorization request must exactly match what you registered — including trailing slashes, protocol, and port number. http://localhost:3000/callback is not the same as http://localhost:3000/callback/.
AADSTS50011: The reply URL specified in the request does not match
the reply URLs configured for the application.
Fix: Update the registered callback URL at https://app.vsaex.visualstudio.com/app/register to exactly match your code.
PAT works in browser but fails in scripts
This usually means you are encoding the PAT incorrectly. Azure DevOps expects Basic auth with an empty username:
// WRONG - includes username
var auth = Buffer.from("username:" + pat).toString("base64");
// CORRECT - empty username, colon required
var auth = Buffer.from(":" + pat).toString("base64");
Refresh token returns "invalid_grant"
Refresh tokens have a maximum lifetime (typically 1 year). Once expired, the user must re-authenticate through the browser flow. There is no way to silently refresh past this point.
{
"Error": "invalid_grant",
"ErrorDescription": "The provided authorization grant is expired."
}
Track refresh token expiry separately and prompt users to re-authenticate before it expires.
"VS30063: You are not authorized to access this collection"
This happens when a PAT is scoped to a specific organization but you are trying to access a different one. PATs created with allOrgs: false only work for the organization where they were created.
# Check which org the PAT belongs to
curl -u :YOUR_PAT https://dev.azure.com/WRONG_ORG/_apis/projects?api-version=7.1
# Returns VS30063
curl -u :YOUR_PAT https://dev.azure.com/CORRECT_ORG/_apis/projects?api-version=7.1
# Returns project list
Token exchange returns 400 with "The assertion is not valid"
The authorization code from the callback has a short lifespan — about 15 minutes. If your token exchange is delayed (maybe due to a slow server restart during development), the code expires. Also verify you are not URL-encoding the code before sending it.
Best Practices
Minimum scope always. Start with the least permissions needed and add scopes only when you hit authorization errors. A PAT with full access is a stolen credential waiting to happen.
Rotate PATs on a schedule. Set calendar reminders or build automated rotation into your infrastructure. Thirty days is a good default for service account PATs. Ninety days maximum for any token used in production.
Use service accounts for pipeline PATs. Never use a personal account's PAT for shared pipelines. When that person leaves the company, every pipeline breaks. Create a dedicated service account with a shared mailbox.
Store tokens in secret management systems. Azure Key Vault, HashiCorp Vault, or at minimum environment variables. Never commit tokens to source control, even in private repositories. Git history is forever.
Implement token health monitoring. Run a daily check against the PAT management API and alert when tokens are within seven days of expiry. One Slack alert is cheaper than a broken production deploy.
Prefer OAuth for user-facing applications. If more than one person uses the tool, OAuth is worth the extra setup time. You get per-user audit trails, the ability to revoke individual access, and no shared secrets.
Log all authentication events. Track successful and failed authentications, token refreshes, and PAT usage. When a security incident happens, you need to know which tokens were used and when.
Validate state parameters in OAuth flows. The state parameter prevents CSRF attacks. Generate cryptographically random values, store them server-side, and verify them on callback. Never skip this step.