OAuth Application Development for Azure DevOps
Complete guide to building OAuth applications for Azure DevOps, covering app registration, authorization flows, token management, scopes, and building secure third-party integrations with Express.js.
OAuth Application Development for Azure DevOps
Overview
OAuth is how you build third-party applications that interact with Azure DevOps on behalf of users — without asking for their password or PAT. When your application needs to read someone's work items, trigger their pipelines, or access their repositories, OAuth lets the user explicitly grant permission through a consent screen. I have built several Azure DevOps integrations that use OAuth — dashboards, Slack bots, custom portals — and the authorization flow has specific quirks that trip up developers who are used to standard OAuth 2.0 implementations.
Prerequisites
- An Azure DevOps organization (any tier)
- A Microsoft account or Azure AD account with access to the Azure DevOps app registration portal
- Node.js 16 or later with Express.js for the web application
- A publicly accessible URL for the callback endpoint (ngrok works for development)
- Understanding of OAuth 2.0 authorization code flow fundamentals
- HTTPS capability for production deployments (OAuth requires secure callbacks)
Registering an OAuth App in Azure DevOps
Azure DevOps OAuth apps are registered at the Visual Studio app registration portal, not the Azure Portal.
Step-by-Step Registration
- Navigate to
https://app.vsaex.visualstudio.com/app/register - Sign in with your Microsoft account
- Fill in the registration form:
| Field | Value | Notes |
|---|---|---|
| Company name | Your Company | Displayed on consent screen |
| Application name | My DevOps Dashboard | Displayed on consent screen |
| Application website | https://yourapp.com | Public URL |
| Authorization callback URL | https://yourapp.com/auth/callback | Must be HTTPS in production |
| Authorized scopes | Select required scopes | See scopes section below |
- Click Create Application
- Note the App ID and Client Secret — you need both
The App ID is a GUID. The Client Secret is a long string. Store both securely — the client secret is shown only once at registration.
OAuth Scopes
Azure DevOps uses its own scope format, not standard OAuth scopes:
| Scope | Permission | Description |
|---|---|---|
vso.work |
Work items (read) | View work items and queries |
vso.work_write |
Work items (read, write) | Create and update work items |
vso.build |
Build (read) | View builds and definitions |
vso.build_execute |
Build (read, execute) | Queue builds |
vso.code |
Code (read) | Clone and read repositories |
vso.code_write |
Code (read, write) | Push to repositories |
vso.code_manage |
Code (read, write, manage) | Create branches, manage PRs |
vso.project |
Project (read) | View projects and teams |
vso.profile |
User profile (read) | Read user's profile information |
vso.identity |
Identity (read) | Read identities and groups |
Request only the scopes your application needs. Users see every requested scope on the consent screen.
OAuth 2.0 Authorization Flow
Azure DevOps uses the standard authorization code grant flow with a few platform-specific details.
Flow Diagram
User Your App Azure DevOps
| | |
|-- Click "Login" ------>| |
| |-- Redirect to /authorize->|
| | |
|<--------- Consent screen displayed ----------------|
| | |
|-- Grant permission --->| |
| |<-- Redirect with code ----|
| | |
| |-- POST /token (code) ---->|
| |<-- Access token + refresh-|
| | |
| |-- API call with token --->|
| |<-- API response ----------|
| | |
|<-- Show dashboard -----| |
Authorization URL
https://app.vssps.visualstudio.com/oauth2/authorize
?client_id={App ID}
&response_type=Assertion
&state={random state}
&scope={scopes}
&redirect_uri={callback URL}
Note: Azure DevOps uses response_type=Assertion, not the standard response_type=code. This is a platform-specific requirement.
Token Exchange
After the user authorizes, Azure DevOps redirects to your callback with a code parameter. Exchange it for tokens:
POST https://app.vssps.visualstudio.com/oauth2/token
Content-Type: application/x-www-form-urlencoded
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion={Client Secret}
&grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion={code}
&redirect_uri={callback URL}
The response contains an access token and refresh token:
{
"access_token": "eyJ0eXAi...",
"token_type": "jwt",
"expires_in": "3599",
"refresh_token": "eyJ0eXAi..."
}
Access tokens expire in 1 hour. Use the refresh token to get new access tokens without re-prompting the user.
Building the Express.js OAuth Application
Project Setup
mkdir devops-oauth-app
cd devops-oauth-app
npm init -y
npm install express express-session uuid
Application Code
// app.js
var express = require("express");
var session = require("express-session");
var https = require("https");
var querystring = require("querystring");
var crypto = require("crypto");
var app = express();
var CLIENT_ID = process.env.AZURE_DEVOPS_APP_ID;
var CLIENT_SECRET = process.env.AZURE_DEVOPS_CLIENT_SECRET;
var CALLBACK_URL = process.env.CALLBACK_URL || "http://localhost:3000/auth/callback";
var SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString("hex");
var SCOPES = "vso.work vso.build vso.code vso.project vso.profile";
app.use(session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 }
}));
app.use(express.json());
// Home page
app.get("/", function(req, res) {
if (req.session.tokens) {
res.send(
"<h1>Azure DevOps Dashboard</h1>" +
"<p>Logged in as: " + (req.session.profile ? req.session.profile.displayName : "unknown") + "</p>" +
"<p><a href=\"/dashboard\">View Dashboard</a></p>" +
"<p><a href=\"/auth/logout\">Logout</a></p>"
);
} else {
res.send(
"<h1>Azure DevOps Dashboard</h1>" +
"<p><a href=\"/auth/login\">Login with Azure DevOps</a></p>"
);
}
});
// Step 1: Redirect to Azure DevOps authorization
app.get("/auth/login", function(req, res) {
var state = crypto.randomBytes(16).toString("hex");
req.session.oauthState = state;
var authUrl = "https://app.vssps.visualstudio.com/oauth2/authorize?" +
querystring.stringify({
client_id: CLIENT_ID,
response_type: "Assertion",
state: state,
scope: SCOPES,
redirect_uri: CALLBACK_URL
});
res.redirect(authUrl);
});
// Step 2: Handle the callback
app.get("/auth/callback", function(req, res) {
var code = req.query.code;
var state = req.query.state;
// Validate state to prevent CSRF
if (state !== req.session.oauthState) {
console.error("State mismatch: expected " + req.session.oauthState + ", got " + state);
return res.status(403).send("Invalid state parameter. Possible CSRF attack.");
}
if (!code) {
console.error("No authorization code received");
return res.status(400).send("Authorization failed. No code received.");
}
// Step 3: Exchange code for tokens
exchangeCodeForTokens(code, function(err, tokens) {
if (err) {
console.error("Token exchange failed:", err.message);
return res.status(500).send("Authentication failed: " + err.message);
}
req.session.tokens = tokens;
req.session.tokenExpiry = Date.now() + (parseInt(tokens.expires_in, 10) * 1000);
// Fetch user profile
fetchProfile(tokens.access_token, function(err2, profile) {
if (err2) {
console.error("Profile fetch failed:", err2.message);
} else {
req.session.profile = profile;
console.log("User authenticated: " + profile.displayName + " (" + profile.emailAddress + ")");
}
res.redirect("/dashboard");
});
});
});
// Token exchange helper
function exchangeCodeForTokens(code, callback) {
var body = querystring.stringify({
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: CLIENT_SECRET,
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: code,
redirect_uri: CALLBACK_URL
});
var options = {
hostname: "app.vssps.visualstudio.com",
path: "/oauth2/token",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"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) {
try { callback(null, JSON.parse(data)); }
catch (e) { callback(new Error("Failed to parse token response")); }
} else {
callback(new Error("Token exchange failed (" + res.statusCode + "): " + data));
}
});
});
req.on("error", callback);
req.write(body);
req.end();
}
// Token refresh helper
function refreshAccessToken(refreshToken, callback) {
var body = querystring.stringify({
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: CLIENT_SECRET,
grant_type: "refresh_token",
assertion: refreshToken,
redirect_uri: CALLBACK_URL
});
var options = {
hostname: "app.vssps.visualstudio.com",
path: "/oauth2/token",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"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) {
try { callback(null, JSON.parse(data)); }
catch (e) { callback(new Error("Failed to parse refresh response")); }
} else {
callback(new Error("Token refresh failed (" + res.statusCode + "): " + data));
}
});
});
req.on("error", callback);
req.write(body);
req.end();
}
// Middleware: ensure authenticated and refresh token if needed
function requireAuth(req, res, next) {
if (!req.session.tokens) {
return res.redirect("/auth/login");
}
// Check if token is expired or expiring within 5 minutes
if (req.session.tokenExpiry && Date.now() > req.session.tokenExpiry - 300000) {
console.log("Access token expiring, refreshing...");
refreshAccessToken(req.session.tokens.refresh_token, function(err, newTokens) {
if (err) {
console.error("Token refresh failed:", err.message);
req.session.destroy(function() {});
return res.redirect("/auth/login");
}
req.session.tokens = newTokens;
req.session.tokenExpiry = Date.now() + (parseInt(newTokens.expires_in, 10) * 1000);
console.log("Token refreshed successfully");
next();
});
} else {
next();
}
}
// Azure DevOps API helper
function devopsAPI(accessToken, org, path, callback) {
var options = {
hostname: "dev.azure.com",
path: "/" + org + path,
method: "GET",
headers: {
"Authorization": "Bearer " + accessToken,
"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) {
try { callback(null, JSON.parse(data)); }
catch (e) { callback(new Error("Parse error")); }
} else {
callback(new Error("API error " + res.statusCode + ": " + data.substring(0, 200)));
}
});
});
req.on("error", callback);
req.end();
}
// Fetch user profile
function fetchProfile(accessToken, callback) {
var options = {
hostname: "app.vssps.visualstudio.com",
path: "/_apis/profile/profiles/me?api-version=7.1",
method: "GET",
headers: {
"Authorization": "Bearer " + accessToken,
"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("Profile error: " + res.statusCode));
}
});
});
req.on("error", callback);
req.end();
}
// Fetch user's organizations
function fetchOrganizations(accessToken, memberId, callback) {
var options = {
hostname: "app.vssps.visualstudio.com",
path: "/_apis/accounts?memberId=" + memberId + "&api-version=7.1",
method: "GET",
headers: {
"Authorization": "Bearer " + accessToken,
"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("Accounts error: " + res.statusCode));
}
});
});
req.on("error", callback);
req.end();
}
// Dashboard route
app.get("/dashboard", requireAuth, function(req, res) {
var accessToken = req.session.tokens.access_token;
var profile = req.session.profile;
// Fetch organizations
fetchOrganizations(accessToken, profile.id, function(err, orgsData) {
if (err) {
return res.status(500).send("Failed to fetch organizations: " + err.message);
}
var orgs = orgsData.value || [];
var html = "<h1>Dashboard</h1>";
html += "<p>Welcome, " + profile.displayName + "</p>";
html += "<h2>Your Organizations</h2>";
html += "<ul>";
orgs.forEach(function(org) {
html += "<li><a href=\"/org/" + org.accountName + "/projects\">" + org.accountName + "</a></li>";
});
html += "</ul>";
html += "<p><a href=\"/\">Home</a> | <a href=\"/auth/logout\">Logout</a></p>";
res.send(html);
});
});
// List projects for an organization
app.get("/org/:org/projects", requireAuth, function(req, res) {
var org = req.params.org;
var accessToken = req.session.tokens.access_token;
devopsAPI(accessToken, org, "/_apis/projects?api-version=7.1", function(err, data) {
if (err) {
return res.status(500).send("Failed to fetch projects: " + err.message);
}
var projects = data.value || [];
var html = "<h1>Projects in " + org + "</h1><ul>";
projects.forEach(function(p) {
html += "<li><strong>" + p.name + "</strong>";
html += " — <a href=\"/org/" + org + "/project/" + p.name + "/workitems\">Work Items</a>";
html += " | <a href=\"/org/" + org + "/project/" + p.name + "/builds\">Builds</a>";
html += " | <a href=\"/org/" + org + "/project/" + p.name + "/repos\">Repos</a>";
html += "</li>";
});
html += "</ul>";
html += "<p><a href=\"/dashboard\">Back to Dashboard</a></p>";
res.send(html);
});
});
// List recent work items
app.get("/org/:org/project/:project/workitems", requireAuth, function(req, res) {
var org = req.params.org;
var project = req.params.project;
var accessToken = req.session.tokens.access_token;
var wiql = JSON.stringify({
query: "SELECT [System.Id], [System.Title], [System.State], [System.AssignedTo] " +
"FROM WorkItems WHERE [System.TeamProject] = '" + project + "' " +
"ORDER BY [System.ChangedDate] DESC"
});
var options = {
hostname: "dev.azure.com",
path: "/" + org + "/" + project + "/_apis/wit/wiql?api-version=7.1&$top=20",
method: "POST",
headers: {
"Authorization": "Bearer " + accessToken,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(wiql)
}
};
var apiReq = https.request(options, function(apiRes) {
var data = "";
apiRes.on("data", function(chunk) { data += chunk; });
apiRes.on("end", function() {
if (apiRes.statusCode !== 200) {
return res.status(500).send("WIQL query failed: " + data.substring(0, 300));
}
var result = JSON.parse(data);
var workItems = result.workItems || [];
if (workItems.length === 0) {
return res.send("<h1>Work Items</h1><p>No work items found.</p><p><a href=\"/org/" + org + "/projects\">Back</a></p>");
}
// Fetch work item details
var ids = workItems.slice(0, 20).map(function(wi) { return wi.id; }).join(",");
devopsAPI(accessToken, org, "/" + project + "/_apis/wit/workitems?ids=" + ids + "&fields=System.Title,System.State,System.AssignedTo&api-version=7.1", function(err2, wiData) {
if (err2) {
return res.status(500).send("Work item fetch failed: " + err2.message);
}
var items = wiData.value || [];
var html = "<h1>Recent Work Items — " + project + "</h1>";
html += "<table border='1' cellpadding='5'><tr><th>ID</th><th>Title</th><th>State</th><th>Assigned To</th></tr>";
items.forEach(function(item) {
var fields = item.fields;
html += "<tr>";
html += "<td>" + item.id + "</td>";
html += "<td>" + (fields["System.Title"] || "") + "</td>";
html += "<td>" + (fields["System.State"] || "") + "</td>";
html += "<td>" + (fields["System.AssignedTo"] ? fields["System.AssignedTo"].displayName : "Unassigned") + "</td>";
html += "</tr>";
});
html += "</table>";
html += "<p><a href=\"/org/" + org + "/projects\">Back to Projects</a></p>";
res.send(html);
});
});
});
apiReq.on("error", function(err) { res.status(500).send("Request error: " + err.message); });
apiReq.write(wiql);
apiReq.end();
});
// List recent builds
app.get("/org/:org/project/:project/builds", requireAuth, function(req, res) {
var org = req.params.org;
var project = req.params.project;
var accessToken = req.session.tokens.access_token;
devopsAPI(accessToken, org, "/" + project + "/_apis/build/builds?$top=20&api-version=7.1", function(err, data) {
if (err) {
return res.status(500).send("Build fetch failed: " + err.message);
}
var builds = data.value || [];
var html = "<h1>Recent Builds — " + project + "</h1>";
html += "<table border='1' cellpadding='5'><tr><th>#</th><th>Definition</th><th>Status</th><th>Result</th><th>Branch</th><th>Finished</th></tr>";
builds.forEach(function(build) {
html += "<tr>";
html += "<td>" + build.buildNumber + "</td>";
html += "<td>" + build.definition.name + "</td>";
html += "<td>" + build.status + "</td>";
html += "<td>" + (build.result || "—") + "</td>";
html += "<td>" + (build.sourceBranch || "").replace("refs/heads/", "") + "</td>";
html += "<td>" + (build.finishTime ? new Date(build.finishTime).toLocaleString() : "running") + "</td>";
html += "</tr>";
});
html += "</table>";
html += "<p><a href=\"/org/" + org + "/projects\">Back to Projects</a></p>";
res.send(html);
});
});
// List repositories
app.get("/org/:org/project/:project/repos", requireAuth, function(req, res) {
var org = req.params.org;
var project = req.params.project;
var accessToken = req.session.tokens.access_token;
devopsAPI(accessToken, org, "/" + project + "/_apis/git/repositories?api-version=7.1", function(err, data) {
if (err) {
return res.status(500).send("Repo fetch failed: " + err.message);
}
var repos = data.value || [];
var html = "<h1>Repositories — " + project + "</h1><ul>";
repos.forEach(function(repo) {
html += "<li><strong>" + repo.name + "</strong> — " + (repo.size / 1024).toFixed(0) + " KB";
html += " — Default branch: " + (repo.defaultBranch || "none").replace("refs/heads/", "");
html += "</li>";
});
html += "</ul>";
html += "<p><a href=\"/org/" + org + "/projects\">Back to Projects</a></p>";
res.send(html);
});
});
// Logout
app.get("/auth/logout", function(req, res) {
req.session.destroy(function(err) {
if (err) { console.error("Session destroy error:", err); }
res.redirect("/");
});
});
// Start server
var PORT = parseInt(process.env.PORT, 10) || 3000;
app.listen(PORT, function() {
console.log("Azure DevOps OAuth app running on http://localhost:" + PORT);
console.log("Callback URL: " + CALLBACK_URL);
console.log("Client ID: " + CLIENT_ID);
});
Running the Application
# Set environment variables
export AZURE_DEVOPS_APP_ID="your-app-id-guid"
export AZURE_DEVOPS_CLIENT_SECRET="your-client-secret"
export CALLBACK_URL="http://localhost:3000/auth/callback"
# Start the app
node app.js
Output:
Azure DevOps OAuth app running on http://localhost:3000
Callback URL: http://localhost:3000/auth/callback
Client ID: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
For development with ngrok:
# In terminal 1
node app.js
# In terminal 2
ngrok http 3000
# Update CALLBACK_URL to the ngrok HTTPS URL
# Update the callback URL in the app registration as well
Token Storage and Security
Never store OAuth tokens in plain text. For production applications, encrypt tokens at rest.
// lib/token-store.js
var crypto = require("crypto");
var fs = require("fs");
var ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY; // 32 bytes hex
var IV_LENGTH = 16;
var ALGORITHM = "aes-256-cbc";
function encrypt(text) {
var iv = crypto.randomBytes(IV_LENGTH);
var cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, "hex"), iv);
var encrypted = cipher.update(text, "utf8");
encrypted = Buffer.concat([encrypted, cipher.final()]);
return iv.toString("hex") + ":" + encrypted.toString("hex");
}
function decrypt(text) {
var parts = text.split(":");
var iv = Buffer.from(parts[0], "hex");
var encrypted = Buffer.from(parts[1], "hex");
var decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, "hex"), iv);
var decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString("utf8");
}
function storeTokens(userId, tokens) {
var encrypted = encrypt(JSON.stringify(tokens));
// In production, store in a database — this uses filesystem for demo
var tokenDir = "./data/tokens";
if (!fs.existsSync(tokenDir)) { fs.mkdirSync(tokenDir, { recursive: true }); }
fs.writeFileSync(tokenDir + "/" + userId + ".enc", encrypted, "utf8");
}
function loadTokens(userId) {
var tokenFile = "./data/tokens/" + userId + ".enc";
if (!fs.existsSync(tokenFile)) { return null; }
var encrypted = fs.readFileSync(tokenFile, "utf8");
return JSON.parse(decrypt(encrypted));
}
function deleteTokens(userId) {
var tokenFile = "./data/tokens/" + userId + ".enc";
if (fs.existsSync(tokenFile)) { fs.unlinkSync(tokenFile); }
}
module.exports = {
storeTokens: storeTokens,
loadTokens: loadTokens,
deleteTokens: deleteTokens
};
Token Revocation
When a user disconnects your app, revoke the token:
// lib/revoke-token.js
var https = require("https");
var querystring = require("querystring");
function revokeToken(accessToken, callback) {
var body = querystring.stringify({
token: accessToken,
token_type_hint: "access_token"
});
var options = {
hostname: "app.vssps.visualstudio.com",
path: "/oauth2/revoke",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
"Authorization": "Bearer " + accessToken
}
};
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.write(body);
req.end();
}
module.exports = { revokeToken: revokeToken };
Common Issues and Troubleshooting
"AADSTS50011: The reply URL specified in the request does not match the reply URLs configured for the application"
Error: AADSTS50011: The reply URL specified in the request does not match the reply URLs configured for the application
The callback URL in your authorization request must match the one registered exactly — same protocol (http vs https), same host, same path, same port. Trailing slashes matter. If you registered https://yourapp.com/auth/callback but your code sends https://yourapp.com/auth/callback/, it will fail. Update either the registration or the code to match.
Token exchange returns "invalid_grant"
{"error":"invalid_grant","error_description":"The provided authorization code is invalid or has expired."}
Authorization codes expire in 15 minutes. If the user delays or your callback is slow, the code expires. Also, codes can only be used once — if you accidentally replay the callback (browser refresh), the second attempt fails. Store tokens in the session immediately and redirect away from the callback URL.
"The access token is not authorized for the requested scope"
HTTP 403: {"message":"The access token is not authorized for the requested scope."}
Your app registration does not include the scope needed for the API call. If you registered with only vso.work but try to access builds, you get a 403. Update the app registration to include the missing scope, then have users re-authorize to grant the new permission.
Refresh token returns "invalid_client"
{"error":"invalid_client","error_description":"The client assertion is invalid."}
The client_assertion (your client secret) is wrong or has been rotated. Client secrets can expire if you set an expiration during registration. Regenerate the secret and update your application configuration. Also verify you are using client_assertion not client_secret — Azure DevOps uses JWT bearer assertion format.
"User did not authorize the request" after consent screen
The user clicked "Deny" on the consent screen. Your callback receives an error parameter instead of a code. Handle this gracefully:
app.get("/auth/callback", function(req, res) {
if (req.query.error) {
console.log("User denied authorization:", req.query.error_description);
return res.send("Authorization was denied. <a href='/'>Try again</a>");
}
// ... normal flow
});
Best Practices
Always validate the
stateparameter. Generate a random state value before redirecting to the authorization URL. Verify it matches when the callback is invoked. This prevents CSRF attacks where an attacker tricks a user into authorizing your app with the attacker's account.Store refresh tokens encrypted, never in plain text. Refresh tokens are long-lived and grant ongoing access. Encrypt them with AES-256 using a key stored in environment variables or Key Vault.
Refresh tokens proactively, not reactively. Check the token expiry before every API call. If the token expires within 5 minutes, refresh it before the call. This avoids failed requests and retry logic.
Request the minimum scopes your application needs. Users see every scope on the consent screen. Requesting
vso.full_accesswhen you only needvso.workerodes trust and violates the principle of least privilege.Implement a logout endpoint that revokes tokens. When users disconnect your app, revoke both the access token and refresh token. Do not just delete the session — that leaves valid tokens in the wild.
Use HTTPS for all callback URLs in production. Azure DevOps requires HTTPS for registered callback URLs in production. HTTP is only allowed for
localhostduring development.Handle token refresh failures by re-authenticating. Refresh tokens can be revoked by the user or expire after extended inactivity. When refresh fails, redirect the user to the login flow instead of showing an error page.