Introduction
Modern APIs require secure authentication mechanisms to protect resources and ensure proper authorization. This guide covers multiple authentication methods suitable for different use cases, from interactive user applications to automated service-to-service communication.
###Context
As enterprise applications increasingly expose APIs through API Management services, requests must present valid access tokens. The authentication method you choose depends on:
- Use case: User-interactive vs automated/background processes
- Security requirements: Level of trust and sensitivity of operations
- Token requirements: Claims, scopes, and audience needs
- Infrastructure: Client capabilities and deployment environment
Key Concepts
Access Token Versions
The version of the access token is controlled by the application manifest configuration, not by the authentication method:
- V1 Tokens: Legacy format with issuer
https://sts.windows.net/{tenant}/ - V2 Tokens: Modern format with issuer
https://login.microsoftonline.com/{tenant}/v2.0
To specify token version, set requestedAccessTokenVersion in the application manifest:
{
"requestedAccessTokenVersion": 2
}
OAuth 2.0 Endpoint Differences
| Aspect | V1 Endpoint | V2 Endpoint |
|---|---|---|
| Path | /oauth2/token |
/oauth2/v2.0/token |
| Scope Parameter | Optional | Required |
| Resource Parameter | Used for audience | Not available |
| Account Types | Azure AD only | Azure AD + Personal Microsoft Accounts |
| Recommendation | Deprecated | Recommended for all new applications |
Authentication Methods Overview
Quick Reference Matrix
| Method | Use Case | Security Level | User Context | Recommended |
|---|---|---|---|---|
| MSAL/PKCE | Interactive web/mobile apps | High | Yes | ✅ Yes |
| ROPC | Testing/legacy | Low | Yes | ⚠️ Not recommended |
| Client Credentials | Service-to-service | Medium-High | No | ✅ Yes (for daemons) |
| Service Accounts | Automated workflows | Medium-High | No | ✅ Yes (with rotation) |
Flow Selection Decision Tree
In short: authorization code with PKCE for interactive users, client credentials for server-to-server calls, a service account for other automated workflows, using a federated or certificate credential rather than a stored secret, and ROPC only as a temporary measure with no place in production.
Method 1: OAuth 2.0 with MSAL Libraries
Overview
Microsoft Authentication Library (MSAL) implements OAuth 2.0 authorization code flow with PKCE, providing the most secure authentication method for interactive applications.
Recommended for:
- Single Page Applications (React, Angular, Vue)
- Mobile applications (iOS, Android)
- Desktop applications
- Any user-facing application
Security Features:
- PKCE (Proof Key for Code Exchange) protection
- Automatic token refresh
- Silent authentication
- Secure token storage
- Support for Multi-Factor Authentication (MFA)
Implementation
Prerequisites
Install MSAL library for your platform:
Browser (JavaScript/TypeScript):
npm install @azure/msal-browser
Java:
<dependency>
<groupId>com.microsoft.azure</groupId>
<artifactId>msal4j</artifactId>
<version>1.17.1</version>
</dependency>
Browser Implementation Example
import * as msal from "@azure/msal-browser";
// Configuration
const msalConfig = {
auth: {
clientId: "{your-application-client-id}",
authority: "https://login.microsoftonline.com/{tenant-id}",
redirectUri: "http://localhost:4200/auth-response",
},
cache: {
cacheLocation: "sessionStorage", // localStorage is NOT safer; both are readable by injected script
storeAuthStateInCookie: false,
},
system: {
loggerOptions: {
logLevel: msal.LogLevel.Info,
loggerCallback: (level, message, containsPii) => {
if (containsPii) return;
console.log(message);
}
}
}
};
// Create MSAL instance
const msalInstance = new msal.PublicClientApplication(msalConfig);
// Login request
const loginRequest = {
scopes: ["{your-application-client-id}/.default"]
};
// Initiate login
function login() {
msalInstance.loginRedirect(loginRequest);
}
// Handle redirect response
msalInstance.handleRedirectPromise()
.then(handleResponse)
.catch(error => {
console.error("Authentication error:", error);
});
function handleResponse(response) {
if (response !== null) {
// Access token received
const accessToken = response.accessToken;
const idToken = response.idToken;
console.log("Access Token:", accessToken);
console.log("User:", response.account);
// Use token to call API
callAPI(accessToken);
} else {
// Need to login
const accounts = msalInstance.getAllAccounts();
if (accounts.length === 0) {
login();
}
}
}
// Acquire token silently (with refresh)
async function getAccessToken() {
const accounts = msalInstance.getAllAccounts();
if (accounts.length === 0) {
throw new Error("No accounts found");
}
const request = {
scopes: ["{your-application-client-id}/.default"],
account: accounts[0]
};
try {
// Try to acquire token silently
const response = await msalInstance.acquireTokenSilent(request);
return response.accessToken;
} catch (error) {
if (error instanceof msal.InteractionRequiredAuthError) {
// Fallback to interactive
return msalInstance.acquireTokenRedirect(request);
}
throw error;
}
}
// Call API with token
async function callAPI(endpoint) {
const token = await getAccessToken();
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
return response.json();
}
// Logout
function logout() {
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) {
msalInstance.logoutRedirect({
account: accounts[0]
});
}
}
Java Implementation Example
import com.microsoft.aad.msal4j.*;
import java.net.URI;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
public class MSALAuthExample {
private final String clientId;
private final String authority;
private final String redirectUri;
public MSALAuthExample(String clientId, String tenantId, String redirectUri) {
this.clientId = clientId;
this.authority = "https://login.microsoftonline.com/" + tenantId;
this.redirectUri = redirectUri;
}
/**
* Initiates interactive authentication flow (for desktop apps)
*/
public IAuthenticationResult acquireTokenInteractive(Set<String> scopes) throws Exception {
PublicClientApplication pca = PublicClientApplication
.builder(clientId)
.authority(authority)
.build();
InteractiveRequestParameters parameters = InteractiveRequestParameters
.builder(new URI(redirectUri))
.scopes(scopes)
.build();
CompletableFuture<IAuthenticationResult> future =
pca.acquireToken(parameters);
return future.get();
}
/**
* Acquires token silently using cached tokens
*/
public IAuthenticationResult acquireTokenSilent(Set<String> scopes,
IAccount account) throws Exception {
PublicClientApplication pca = PublicClientApplication
.builder(clientId)
.authority(authority)
.build();
SilentParameters parameters = SilentParameters
.builder(scopes, account)
.build();
try {
CompletableFuture<IAuthenticationResult> future =
pca.acquireTokenSilently(parameters);
return future.get();
} catch (Exception ex) {
// If silent fails, fall back to interactive
return acquireTokenInteractive(scopes);
}
}
/**
* Usage example
*/
public static void main(String[] args) throws Exception {
MSALAuthExample auth = new MSALAuthExample(
"{your-client-id}",
"{your-tenant-id}",
"http://localhost:8080/auth-response"
);
Set<String> scopes = Collections.singleton("{client-id}/.default");
// Acquire token interactively
IAuthenticationResult result = auth.acquireTokenInteractive(scopes);
String accessToken = result.accessToken();
System.out.println("Access token: " + accessToken);
// Use token to call API
callAPI("https://api.example.com/data", accessToken);
}
private static void callAPI(String endpoint, String accessToken) {
// Implementation for API call with Bearer token
}
}
Token Response
Successful Response:
{
"token_type": "Bearer",
"scope": "{client-id}/.default",
"expires_in": 3599,
"ext_expires_in": 3599,
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"refresh_token": "0.ATEA7H99..."
}
Best Practices
Token Storage
- Browser: Use
sessionStorageor secure cookies (HttpOnly) - Mobile: Use platform keychain/keystore
- Keep access tokens in memory, or behind a backend-for-frontend holding them in
HttpOnlycookies.sessionStoragelimits persistence but gives no protection against XSS — treat it exactly likelocalStorage
- Browser: Use
Token Refresh
- Use
acquireTokenSilent()to refresh automatically - Implement retry logic for transient failures
- Handle
InteractionRequiredAuthErrorappropriately
- Use
Error Handling
- Catch and handle all MSAL exceptions
- Implement graceful fallback to interactive auth
- Log errors (excluding PII)
Security
- Always use HTTPS
- Validate redirect URIs
- Implement Content Security Policy (CSP)
- Enable MFA for all users
Method 2: Resource Owner Password Credentials (ROPC)
Overview
The Resource Owner Password Credentials (ROPC) flow allows an application to sign in a user by directly handling their password.
⚠️ Security Warning
Microsoft strongly discourages use of this flow:
"Microsoft recommends you do not use the ROPC flow. In most scenarios, more secure alternatives are available and recommended. This flow requires a very high degree of trust in the application, and carries risks that are not present in other flows. You should only use this flow when other more secure flows aren't viable."
Risks:
- Exposes user passwords to the client application
- No support for Multi-Factor Authentication (MFA)
- No consent screen or user interaction
- Higher risk of credential theft
- Violates zero-trust principles
Valid Use Cases (Limited):
- Automated testing environments
- Legacy system migration (temporary)
- Internal tools with high trust (short-term)
V2 Endpoint Implementation
HTTP Request:
POST https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
client_id={application-client-id}
&client_secret={application-client-secret}
&scope={application-client-id}/.default
&username={[email protected]}
&password={user-password}
&grant_type=password
cURL Example:
curl --location 'https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id={application-client-id}' \
--data-urlencode 'scope={application-client-id}/.default' \
--data-urlencode 'client_secret={application-client-secret}' \
--data-urlencode '[email protected]' \
--data-urlencode 'password=SecurePassword123!' \
--data-urlencode 'grant_type=password'
V1 Endpoint Implementation (Deprecated)
HTTP Request:
POST https://login.microsoftonline.com/{tenant-id}/oauth2/token
Content-Type: application/x-www-form-urlencoded
client_id={application-client-id}
&client_secret={application-client-secret}
&scope=openid
&username={[email protected]}
&password={user-password}
&grant_type=password
&resource={application-client-id}
Parameter Details
| Parameter | Required | Description |
|---|---|---|
client_id |
Yes | Application (client) ID from app registration |
client_secret |
Yes | Client secret from app registration (keep secure) |
scope |
Yes (V2) | {client-id}/.default to request all app permissions |
resource |
Yes (V1) | Resource identifier (typically same as client_id) |
username |
Yes | User's UPN ([email protected]) |
password |
Yes | User's password (transmitted securely over HTTPS) |
grant_type |
Yes | Must be password |
Response Example
V2 Endpoint Success Response:
{
"token_type": "Bearer",
"scope": "{client-id}/Directory.AccessAsUser.All {client-id}/User.Read {client-id}/.default",
"expires_in": 43199,
"ext_expires_in": 43199,
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllUY2VPNUl..."
}
Decoded V2 Access Token Claims:
{
"aud": "{application-client-id}",
"iss": "https://login.microsoftonline.com/{tenant-id}/v2.0",
"iat": 1739194509,
"nbf": 1739194509,
"exp": 1739238009,
"roles": [
"user",
"department-finance",
"core-users"
],
"scp": "Directory.AccessAsUser.All User.Read",
"preferred_username": "[email protected]",
"name": "John Doe",
"ver": "2.0"
}
Migration Path
If currently using ROPC, plan migration to:
- Short-term: Implement client credentials for service accounts
- Medium-term: Migrate to authorization code flow with PKCE for user-facing apps
- Long-term: Completely eliminate ROPC usage
Migration Example (JavaScript - Browser):
// OLD: ROPC (not recommended - requires backend)
async function getTokenROPC(username, password) {
const response = await fetch(
`https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`,
{
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
'client_id': CLIENT_ID,
'client_secret': CLIENT_SECRET,
'scope': `${CLIENT_ID}/.default`,
'username': username,
'password': password,
'grant_type': 'password'
})
}
);
const data = await response.json();
return data.access_token;
}
// NEW: Authorization Code Flow with MSAL (recommended)
import * as msal from "@azure/msal-browser";
const msalConfig = {
auth: {
clientId: CLIENT_ID,
authority: `https://login.microsoftonline.com/${TENANT_ID}`,
redirectUri: window.location.origin + "/auth-response"
}
};
const msalInstance = new msal.PublicClientApplication(msalConfig);
// Interactive authentication with PKCE (automatic)
const loginRequest = {
scopes: [`${CLIENT_ID}/.default`]
};
const response = await msalInstance.loginPopup(loginRequest);
const accessToken = response.accessToken;
Method 3: Client Credentials Flow
Overview
The OAuth 2.0 Client Credentials Flow permits a web service (confidential client) to use its own credentials to authenticate when calling another web service. This is sometimes called "two-legged OAuth" and is ideal for server-to-server interactions.
Characteristics:
- No user context involved
- Application authenticates as itself
- Suitable for daemons and background services
- Access token subject is the client/application, not a user
Recommended for:
- Scheduled jobs and cron tasks
- Background data processing
- Service-to-service API calls
- Automated workflows
- Daemon applications
Choosing a Credential Type
Client credentials flow proves the application is who it claims to be. Entra ID accepts three different forms of proof at the token endpoint, and they are not equivalent in risk. In order of preference:
- Workload identity federation — no secret exists anywhere. The workload presents a token issued by its own platform (a GitHub Actions run, a Kubernetes pod, an Azure or AWS workload identity) and Entra exchanges it directly for an access token.
- Certificate credentials — the application signs a short-lived assertion with a private key that never leaves the caller.
- Client secret — a shared string, presented as-is. The fallback, not the default.
RFC 9700 §2.5 recommends asymmetric client authentication — mTLS or a signed JWT — over shared secrets. Microsoft's Entra guidance goes further and steers workloads towards federated credentials, which need no stored key at all. Taken together, that gives the order above. A client secret is a bearer credential: whoever holds the string can authenticate as the application, with no way to distinguish that request from a genuine one. Rotation limits how long a leaked secret stays useful; it does nothing to prevent the leak, which is why the two options below, neither of which requires a stored shared secret are the ones to reach for first.
Workload Identity Federation (Recommended)
With federation, there is no secret to store, leak, or rotate. Instead you register a federated credential on the app registration: an issuer URL and an exact subject claim. Entra will trust a token for that app registration only if it is signed by that issuer and its subject claim matches exactly — so only a workload run from that specific GitHub repository, Kubernetes service account, or cloud identity can obtain a token. Nothing else can, even if it presents a perfect copy of every other field.
The trust chain:
- You register: issuer (e.g.
https://token.actions.githubusercontent.com) + subject. Note that GitHub changed this format on 15 July 2026: repositories created after that date — and any repository renamed or transferred since — use immutable identifiers,repo:{org}@{org-id}/{repo}@{repo-id}:ref:refs/heads/main, rather than the olderrepo:{org}/{repo}:ref:refs/heads/main. The change exists because names can be released and re-registered, so a mutable subject could later be claimed by somebody else. Check which format your repository issues before writing the federated credential + audience (api://AzureADTokenExchange) - GitHub's OIDC provider signs a token asserting that subject only for workflow runs matching it
- Entra checks the signature against GitHub's published keys and the subject against what you registered, then issues an access token — no secret ever crosses the wire
GitHub Actions example (no stored secret):
name: Call API with Federated Identity
on:
workflow_dispatch:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
permissions:
id-token: write # required to request the OIDC token
contents: read
jobs:
call-api:
runs-on: ubuntu-latest
steps:
- name: Request a GitHub OIDC token
id: github-token
run: |
RESPONSE=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange")
TOKEN=$(echo "$RESPONSE" | jq -r '.value')
echo "::add-mask::$TOKEN"
echo "token=$TOKEN" >> $GITHUB_OUTPUT
- name: Exchange it for an Entra access token
id: get-token
env:
# Passed as an environment variable, never interpolated into the
# script body. A ${{ }} expression is substituted textually before
# the shell runs, so anything it carries becomes part of the command.
GITHUB_OIDC_TOKEN: ${{ steps.github-token.outputs.token }}
run: |
RESPONSE=$(curl -s --location 'https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id={application-client-id}' \
--data-urlencode 'scope={target-api-client-id}/.default' \
--data-urlencode 'client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
--data-urlencode "client_assertion=$GITHUB_OIDC_TOKEN")
TOKEN=$(echo "$RESPONSE" | jq -r '.access_token')
echo "::add-mask::$TOKEN"
echo "token=$TOKEN" >> $GITHUB_OUTPUT
- name: Call API
run: |
curl -X GET \
-H "Authorization: Bearer ${{ steps.get-token.outputs.token }}" \
-H "Content-Type: application/json" \
https://api.example.com/endpoint
Notice there are no secrets.* references anywhere in this workflow — nothing in GitHub's secret store to rotate, expire, or accidentally print to a log. The GitHub-issued token in github-token is itself scoped to this one workflow run and is short-lived — GitHub's reference example sets a fifteen-minute window, so even capturing it in a build log leaves an attacker very little time to use it before Entra would reject it as expired.
The same pattern applies to other platforms with their own OIDC-compatible identity provider — register the platform's issuer and subject format as the federated credential and skip the secret entirely:
- Kubernetes: the pod's projected service account token, issuer set to the cluster's OIDC discovery endpoint
- Azure-hosted workloads: use a managed identity directly — it is platform-issued credentials from the instance metadata endpoint, not federation. Where the workload must act as a specific app registration, a user-assigned managed identity can itself be registered as that app's federated credential.
- AWS workloads on EKS: the pod's projected service account token, exactly as for any other Kubernetes cluster.
- AWS workloads elsewhere (EC2, Lambda, ECS): enable outbound identity federation on the account, then call
sts:GetWebIdentityTokenfor a signed JWT from your account-specific OIDC issuer. The external provider validates it against that issuer's/.well-known/jwks.json, the same as any other OIDC token. - The trap worth naming: ordinary temporary STS credentials — access key, secret, session token — are not a federation subject token and cannot be exchanged this way. Federation needs a signed OIDC JWT. The service account token and
GetWebIdentityTokenproduce one;AssumeRoledoes not.
Certificate Credentials
When the calling platform has no OIDC identity of its own to federate — an on-premises scheduler, for instance — a certificate is the next best option. Instead of a shared secret, the application holds a private key and signs a short-lived JWT (the client assertion) asserting its own identity. Entra holds only the public key, uploaded to the app registration in advance.
HTTP Request (client_assertion form):
POST https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
client_id={application-client-id}
&scope={application-client-id}/.default
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion={signed-jwt}
&grant_type=client_credentials
Assertion JWT header — without this Entra cannot tell which registered certificate signed the assertion, and rejects it:
{
"alg": "PS256",
"typ": "JWT",
"x5t#S256": "<base64url SHA-256 thumbprint of the certificate's DER encoding>"
}
The signature uses PSS padding, which is what PS256 selects. x5t#S256 is the lookup key: it tells Entra which of the certificates registered against the application to verify with.
Assertion JWT claims:
{
"aud": "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token",
"iss": "{application-client-id}",
"sub": "{application-client-id}",
"jti": "{unique-guid}",
"nbf": 1739290214,
"exp": 1739290514
}
The short exp — typically five to ten minutes — matters: this assertion is only ever used once, to prove possession of the private key for this single token request. MSAL will build and sign it for you from a certificate rather than requiring you to hand-construct the JWT:
import msal
app = msal.ConfidentialClientApplication(
client_id=CLIENT_ID,
authority=f"https://login.microsoftonline.com/{TENANT_ID}",
client_credential={
"private_key": open("service-principal.pem").read(),
"thumbprint": CERT_THUMBPRINT,
},
)
result = app.acquire_token_for_client(scopes=[f"{CLIENT_ID}/.default"])
access_token = result["access_token"]
Store the private key the same way you would any other high-value secret (an HSM or a key vault, ideally one that never exports it), but the operational risk it carries is different in kind from a client secret: an attacker who captures a client_assertion can only use it until its exp, minutes away, and gains nothing that lets them mint assertions of their own. That is why the expiry must be short and the request must go over TLS. A leaked client secret, by contrast, is good until somebody notices. To mint assertions of their own an attacker would need the private key itself, which never appears in the request.
Shared Secret Credentials (Fallback)
Reach for a client secret only when the platform genuinely cannot hold a private key or federate an identity — for example, a legacy on-premises tool with no secret-store or certificate-store integration. Everything in this section is standard OAuth 2.0 client credentials with a shared secret; treat the controls below as damage limitation rather than a control that stops a leak from happening.
V2 Endpoint Implementation (Recommended)
HTTP Request:
POST https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
client_id={application-client-id}
&client_secret={application-client-secret}
&scope={application-client-id}/.default
&grant_type=client_credentials
cURL Example:
curl --location 'https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id={application-client-id}' \
--data-urlencode 'scope={application-client-id}/.default' \
--data-urlencode 'client_secret={application-client-secret}'
V1 Endpoint Implementation (Deprecated)
HTTP Request:
POST https://login.microsoftonline.com/{tenant-id}/oauth2/token
Content-Type: application/x-www-form-urlencoded
client_id={application-client-id}
&client_secret={application-client-secret}
&scope=openid
&grant_type=client_credentials
&resource={application-client-id}
Parameter Details
| Parameter | Required | Description |
|---|---|---|
client_id |
Yes | Application (client) ID |
client_secret |
Yes | Client secret (must be stored securely) |
scope |
Yes (V2) | {client-id}/.default grants all app permissions |
resource |
Yes (V1) | Resource/audience for the token |
grant_type |
Yes | Must be client_credentials |
Application Roles and Permissions
Unlike user-based flows, client credentials use application permissions (not delegated permissions).
Granting Application Roles:
- Define app roles in application manifest:
{
"appRoles": [
{
"allowedMemberTypes": ["Application"],
"displayName": "Service.Read",
"id": "unique-guid",
"isEnabled": true,
"description": "Allow service to read data",
"value": "Service.Read"
}
]
}
- Grant admin consent programmatically or via portal:
GET https://login.microsoftonline.com/{tenant-id}/adminconsent
?client_id={application-client-id}
&state=12345
&redirect_uri={redirect-uri}
Response Example
V2 Success Response:
{
"token_type": "Bearer",
"expires_in": 43199,
"ext_expires_in": 43199,
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImltaT..."
}
Decoded V2 Token Claims:
{
"aud": "{application-client-id}",
"iss": "https://login.microsoftonline.com/{tenant-id}/v2.0",
"iat": 1739290214,
"nbf": 1739290214,
"exp": 1739333714,
"azp": "{application-client-id}",
"azpacr": "1",
"oid": "{service-principal-object-id}",
"roles": [
"Service.Read",
"Service.Write"
],
"sub": "{service-principal-object-id}",
"tid": "{tenant-id}",
"ver": "2.0"
}
Key Differences from User Tokens:
- No
upnorpreferred_usernameclaims (no user context) subandoididentify the service principalrolescontains application permissions, not user roles- No
scp(scopes) claim for delegated permissions
Implementation Examples
The two examples below authenticate with a client secret, for parity with the fallback path described above — swap the token request for the certificate or federated-identity variants shown earlier and the rest of each client is unchanged.
JavaScript (Node.js Backend)
/**
* Client Credentials Flow for Node.js backend services
* Using native fetch API (Node.js 18+)
*/
class ClientCredentialsAuth {
constructor(tenantId, clientId, clientSecret) {
this.tenantId = tenantId;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
this.cachedToken = null;
this.tokenExpiry = null;
}
async getAccessToken() {
// Return cached token if still valid
if (this.cachedToken && this.tokenExpiry > Date.now() + 60000) {
console.log('Using cached token');
return this.cachedToken;
}
console.log('Acquiring new access token');
const params = new URLSearchParams({
'grant_type': 'client_credentials',
'client_id': this.clientId,
'client_secret': this.clientSecret,
'scope': `${this.clientId}/.default`
});
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token acquisition failed: ${error}`);
}
const data = await response.json();
// Cache token with 5-minute buffer before expiry
this.cachedToken = data.access_token;
this.tokenExpiry = Date.now() + (data.expires_in * 1000) - 300000;
return this.cachedToken;
}
async callAPI(endpoint, method = 'GET', body = null) {
const token = await this.getAccessToken();
const options = {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(endpoint, options);
if (!response.ok) {
throw new Error(`API call failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
}
// Usage
const auth = new ClientCredentialsAuth(
process.env.TENANT_ID,
process.env.CLIENT_ID,
process.env.CLIENT_SECRET
);
// Call API
const data = await auth.callAPI('https://api.example.com/data');
console.log(data);
Java
import com.microsoft.aad.msal4j.*;
import java.net.http.*;
import java.net.URI;
import java.util.Collections;
import java.util.concurrent.CompletableFuture;
import java.time.Instant;
/**
* Client Credentials authentication for Java backend services
* Includes token caching and API call example
*/
public class ClientCredentialsAuth {
private final String clientId;
private final String clientSecret;
private final String tenantId;
private final String authority;
private final ConfidentialClientApplication app;
private String cachedToken;
private Instant tokenExpiry;
public ClientCredentialsAuth(String clientId, String clientSecret, String tenantId)
throws Exception {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.tenantId = tenantId;
this.authority = "https://login.microsoftonline.com/" + tenantId;
// Build confidential client application
this.app = ConfidentialClientApplication
.builder(clientId, ClientCredentialFactory.createFromSecret(clientSecret))
.authority(authority)
.build();
}
/**
* Acquires access token with caching
*/
public String getAccessToken() throws Exception {
// Return cached token if still valid (5-minute buffer)
if (cachedToken != null &&
tokenExpiry != null &&
Instant.now().plusSeconds(300).isBefore(tokenExpiry)) {
System.out.println("Using cached token");
return cachedToken;
}
System.out.println("Acquiring new access token");
ClientCredentialParameters params = ClientCredentialParameters
.builder(Collections.singleton(clientId + "/.default"))
.build();
CompletableFuture<IAuthenticationResult> future = app.acquireToken(params);
IAuthenticationResult result = future.get();
// Cache token
this.cachedToken = result.accessToken();
this.tokenExpiry = result.expiresOnDate().toInstant();
System.out.println("Token expires at: " + tokenExpiry);
return cachedToken;
}
/**
* Calls API with bearer token authentication
*/
public String callAPI(String endpoint) throws Exception {
String token = getAccessToken();
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(endpoint))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.GET()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() == 401) {
// Token might be invalid, clear cache and retry
cachedToken = null;
tokenExpiry = null;
return callAPI(endpoint);
}
if (response.statusCode() != 200) {
throw new RuntimeException(
"API call failed: " + response.statusCode() + " " + response.body()
);
}
return response.body();
}
/**
* Usage example
*/
public static void main(String[] args) throws Exception {
// Load configuration from environment variables
String tenantId = System.getenv("TENANT_ID");
String clientId = System.getenv("CLIENT_ID");
String clientSecret = System.getenv("CLIENT_SECRET");
ClientCredentialsAuth auth = new ClientCredentialsAuth(
clientId,
clientSecret,
tenantId
);
// Call API
String apiEndpoint = "https://api.example.com/data";
String response = auth.callAPI(apiEndpoint);
System.out.println("API Response: " + response);
}
}
Security Best Practices
Credential Storage
- Store client secrets in secure vaults (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault)
- Never hardcode secrets in source code
- Use managed identities when possible
Secret Rotation
- Rotate secrets every 30-90 days
- Implement zero-downtime rotation (have 2 secrets active during transition)
- Automate rotation process
Scope Limitation
- Request only necessary permissions
- Use multiple service principals for different purposes
- Implement principle of least privilege
Monitoring
- Log all token acquisitions
- Monitor for unusual patterns
- Alert on failed authentication attempts
- Audit service principal usage regularly
Client Management
- Document all service principals and their purposes
- Regularly review and delete unused clients
- Implement naming conventions for easy identification
Method 4: Service Account Authentication
Overview
Service accounts provide authentication for automated workflows, integration platforms, and orchestration tools without direct code implementation.
Common Platforms:
- Azure Logic Apps
- Power Automate
- Apache Airflow
- Jenkins
- GitHub Actions
- GitLab CI/CD
Azure Logic Apps Example
Logic Apps support Active Directory OAuth authentication type for HTTP actions.
Configuration Parameters:
| Parameter | Value | Description |
|---|---|---|
| Tenant | {tenant-id} |
Azure AD tenant identifier |
| Client ID | {client-id} |
Application client ID |
| Credential Type | Secret |
Use client secret authentication |
| Secret | {client-secret} |
Client secret value (store in Key Vault) |
| Audience | {resource-client-id} |
Target API application ID |
Visual Configuration:
In Logic App designer:
- Add HTTP action
- Select Authentication type: Active Directory OAuth
- Fill in parameters above
- Token is automatically acquired and refreshed
Logic App Definition (Code View):
{
"HTTP": {
"type": "Http",
"inputs": {
"uri": "https://api.example.com/data",
"method": "GET",
"authentication": {
"type": "ActiveDirectoryOAuth",
"authority": "",
"tenant": "{tenant-id}",
"audience": "{application-client-id}",
"clientId": "{application-client-id}",
"secret": "@{parameters('ClientSecret')}"
}
},
"runAfter": {},
"runtimeConfiguration": {
"contentTransfer": {
"transferMode": "Chunked"
}
}
}
}
Using Azure Key Vault:
{
"parameters": {
"$connections": {
"value": {
"keyvault": {
"connectionId": "/subscriptions/{sub-id}/resourceGroups/{rg}/providers/Microsoft.Web/connections/keyvault",
"connectionName": "keyvault",
"id": "/subscriptions/{sub-id}/providers/Microsoft.Web/locations/{location}/managedApis/keyvault"
}
}
}
}
}
GitHub Actions Example
Using GitHub Actions with OIDC (OpenID Connect) for secure authentication:
name: Call API with Authentication
on:
workflow_dispatch:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
permissions:
id-token: write
contents: read
jobs:
call-api:
runs-on: ubuntu-latest
steps:
- name: Azure Login
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Get Access Token
id: get-token
run: |
TOKEN=$(az account get-access-token \
--resource ${{ secrets.API_CLIENT_ID }} \
--query accessToken -o tsv)
echo "::add-mask::$TOKEN"
echo "token=$TOKEN" >> $GITHUB_OUTPUT
- name: Call API
run: |
curl -X GET \
-H "Authorization: Bearer ${{ steps.get-token.outputs.token }}" \
-H "Content-Type: application/json" \
https://api.example.com/endpoint
Jenkins Pipeline Example
pipeline {
agent any
environment {
TENANT_ID = credentials('azure-tenant-id')
CLIENT_ID = credentials('azure-client-id')
CLIENT_SECRET = credentials('azure-client-secret')
API_ENDPOINT = 'https://api.example.com/data'
}
stages {
stage('Get Access Token') {
steps {
script {
def response = sh(
script: """
curl -s -X POST \
'https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token' \
-d 'grant_type=client_credentials' \
-d 'client_id=${CLIENT_ID}' \
-d 'client_secret=${CLIENT_SECRET}' \
-d 'scope=${CLIENT_ID}/.default'
""",
returnStdout: true
).trim()
def json = readJSON text: response
env.ACCESS_TOKEN = json.access_token
}
}
}
stage('Call API') {
steps {
script {
sh """
curl -X GET \
-H 'Authorization: Bearer ${ACCESS_TOKEN}' \
-H 'Content-Type: application/json' \
'${API_ENDPOINT}'
"""
}
}
}
}
}
Token Claims from Service Account
Logic Apps / Automated Workflow Token:
{
"aud": "{application-client-id}",
"iss": "https://sts.windows.net/{tenant-id}/",
"iat": 1738941853,
"nbf": 1738941853,
"exp": 1738985353,
"appid": "{client-app-id}",
"appidacr": "1",
"idp": "https://sts.windows.net/{tenant-id}/",
"oid": "{service-principal-object-id}",
"roles": [
"Workflow.Execute",
"Data.Read"
],
"sub": "{service-principal-object-id}",
"tid": "{tenant-id}",
"ver": "1.0"
}
Best Practices for Service Accounts
Credential Management
- Use platform secret management (GitHub Secrets, Azure Key Vault, Jenkins Credentials)
- Rotate secrets regularly (90-day maximum)
- Never log secrets or tokens
- Use environment variables, never hardcode
Least Privilege
- Grant only necessary permissions
- Create separate service accounts per workflow/purpose
- Review permissions quarterly
Monitoring and Auditing
- Log all service account activity
- Set up alerts for unusual patterns
- Monitor token acquisition failures
- Track API usage by service account
Token Caching
- Cache tokens for their lifetime (usually 1 hour)
- Implement refresh logic before expiry
- Handle token expiration gracefully
Error Handling
- Implement retry logic with exponential backoff
- Handle 401 (Unauthorized) by acquiring new token
- Handle 429 (Rate Limit) with appropriate delays
- Log errors for troubleshooting
Token Version Comparison (V1 vs V2)
Configuration
Token version is controlled by the requestedAccessTokenVersion property in the application manifest:
{
"id": "unique-app-id",
"appId": "client-id-guid",
"requestedAccessTokenVersion": 2,
"signInAudience": "AzureADMyOrg"
}
V1 Token Example
Issuer: https://sts.windows.net/{tenant-id}/
Key Claims:
{
"aud": "{application-client-id}",
"iss": "https://sts.windows.net/{tenant-id}/",
"iat": 1739193566,
"nbf": 1739193566,
"exp": 1739237066,
"acr": "1",
"amr": ["pwd"],
"appid": "{client-id}",
"appidacr": "1",
"ipaddr": "192.168.1.100",
"oid": "{user-object-id}",
"roles": [
"User",
"Department.Finance",
"CoreUser"
],
"scp": "Directory.AccessAsUser.All User.Read",
"unique_name": "[email protected]",
"upn": "[email protected]",
"ver": "1.0",
"family_name": "Doe",
"given_name": "John",
"name": "John Doe"
}
V2 Token Example
Issuer: https://login.microsoftonline.com/{tenant-id}/v2.0
Key Claims:
{
"aud": "{application-client-id}",
"iss": "https://login.microsoftonline.com/{tenant-id}/v2.0",
"iat": 1739194509,
"nbf": 1739194509,
"exp": 1739238009,
"azp": "{authorized-party-client-id}",
"azpacr": "1",
"oid": "{user-object-id}",
"preferred_username": "[email protected]",
"roles": [
"User",
"Department.Finance",
"CoreUser"
],
"scp": "Directory.AccessAsUser.All User.Read",
"sub": "{subject-identifier}",
"tid": "{tenant-id}",
"upn": "[email protected]",
"ver": "2.0",
"family_name": "Doe",
"given_name": "John",
"name": "John Doe"
}
Key Differences
| Aspect | V1 Token | V2 Token |
|---|---|---|
| Issuer | sts.windows.net/{tenant}/ |
login.microsoftonline.com/{tenant}/v2.0 |
| Version Claim | ver: "1.0" |
ver: "2.0" |
| Authorized Party | appid |
azp (more explicit) |
| Name Claims | unique_name, upn |
preferred_username, upn |
| Account Types | Azure AD only | Azure AD + Microsoft Accounts |
| Token Size | Smaller | Slightly larger |
| Compatibility | Legacy apps | Modern apps |
| Recommendation | Migrate away | Use for new apps |
Migration Considerations
When to use V2 tokens:
- All new applications
- Applications requiring Microsoft Account support
- Modern authentication implementations
- When using latest MSAL libraries
When V1 might still be needed:
- Legacy applications not yet migrated
- Specific compatibility requirements
- Gradual migration scenarios
Migration Steps:
- Update application manifest:
requestedAccessTokenVersion: 2 - Update token validation to accept V2 issuer
- Update claim extraction (use
preferred_usernameinstead ofunique_name) - Test thoroughly in non-production environments
- Deploy gradually with monitoring
Security Best Practices
1. Credential Protection
Never Hardcode Secrets:
# ❌ BAD - Never do this
client_secret = "V2h8Q~secretvalue123"
# ✅ GOOD - Use environment variables
import os
client_secret = os.environ.get('CLIENT_SECRET')
# ✅ BETTER - Use secret management service
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
credential = DefaultAzureCredential()
secret_client = SecretClient(vault_url="https://myvault.vault.azure.net", credential=credential)
client_secret = secret_client.get_secret("ClientSecret").value
Secret Storage Options:
- Azure Key Vault - Azure-native secret management
- AWS Secrets Manager - AWS secret storage
- HashiCorp Vault - Multi-cloud secret management
- GitHub Secrets - For CI/CD workflows
- Environment Variables - For local development (with
.envfiles not committed)
2. Token Handling
Secure Token Storage:
// ❌ BAD - localStorage is vulnerable to XSS
localStorage.setItem('access_token', token);
// ✅ GOOD - Use HttpOnly cookies (set by backend)
// Cookie: access_token={token}; HttpOnly; Secure; SameSite=Strict
// ✅ GOOD - In-memory storage for SPAs
class TokenManager {
constructor() {
this.token = null;
}
setToken(token) {
this.token = token;
// Automatically clear after expiry
setTimeout(() => {
this.token = null;
}, 3600 * 1000); // 1 hour
}
getToken() {
return this.token;
}
}
Token Transmission:
- Always use HTTPS
- Send tokens in
Authorizationheader (not query string) - Implement request signing for additional security
- Use short-lived tokens (5-60 minutes)
3. Rotation and Expiry
Secret Rotation Strategy:
class SecretRotationManager:
def __init__(self, vault_client):
self.vault = vault_client
self.current_secret = None
self.next_secret = None
def rotate_secret(self):
# Generate new secret
new_secret = self.generate_new_secret()
# Store as "next" secret
self.vault.set_secret("ClientSecret-Next", new_secret)
# Wait for propagation (grace period)
time.sleep(60)
# Promote next to current
self.vault.set_secret("ClientSecret", new_secret)
# Wait for old secret to be phased out
time.sleep(300) # 5 minutes
# Delete old secret
self.vault.delete_secret("ClientSecret-Next")
def get_active_secret(self):
# Try current first, fallback to next during rotation
try:
return self.vault.get_secret("ClientSecret")
except:
return self.vault.get_secret("ClientSecret-Next")
Rotation Schedule:
- Client Secrets: Every 30-90 days
- API Keys: Every 90 days
- Service Account Passwords: Every 60 days
- Certificates: Before expiry (typically 1 year)
4. Scope and Permission Management
Principle of Least Privilege:
// ❌ BAD - Requesting all permissions
const scopes = ["{client-id}/.default"];
// ✅ GOOD - Request specific scopes
const scopes = [
"api://myapi/Data.Read",
"api://myapi/Reports.Generate"
];
// ✅ GOOD - Different scopes for different operations
const readScopes = ["api://myapi/Data.Read"];
const writeScopes = ["api://myapi/Data.Write"];
async function getData() {
const token = await getToken(readScopes);
return callAPI(token, "/data");
}
async function updateData(data) {
const token = await getToken(writeScopes);
return callAPI(token, "/data", "POST", data);
}
5. Rate Limiting and Throttling
Implement Backoff Strategy:
import time
from requests.exceptions import HTTPError
def call_api_with_retry(endpoint, token, max_retries=3):
for attempt in range(max_retries):
try:
response = requests.get(
endpoint,
headers={'Authorization': f'Bearer {token}'}
)
response.raise_for_status()
return response.json()
except HTTPError as e:
if e.response.status_code == 429: # Rate limited
retry_after = int(e.response.headers.get('Retry-After', 60))
if attempt < max_retries - 1:
time.sleep(retry_after)
continue
raise
elif e.response.status_code == 401: # Unauthorized
# Token expired, refresh and retry
token = refresh_token()
if attempt < max_retries - 1:
continue
raise
else:
raise
6. Monitoring and Alerting
Log Authentication Events:
import logging
from datetime import datetime
logger = logging.getLogger('auth')
def get_access_token():
try:
start_time = datetime.utcnow()
token_response = acquire_token()
logger.info(f"Token acquired successfully", extra={
'client_id': CLIENT_ID,
'scope': SCOPE,
'duration_ms': (datetime.utcnow() - start_time).total_seconds() * 1000,
'token_expiry': token_response['expires_in']
})
return token_response['access_token']
except Exception as e:
logger.error(f"Token acquisition failed", extra={
'client_id': CLIENT_ID,
'error': str(e),
'error_type': type(e).__name__
})
raise
Metrics to Monitor:
- Token acquisition success/failure rates
- Token acquisition latency
- Authentication failures by client
- Unusual access patterns
- Token expiry before refresh
- Rate limit hits
7. HTTPS and Transport Security
Enforce HTTPS:
import requests
# ❌ BAD - Allow insecure connections
response = requests.get('http://api.example.com', verify=False)
# ✅ GOOD - Enforce HTTPS
if not endpoint.startswith('https://'):
raise ValueError("Only HTTPS endpoints are allowed")
response = requests.get(endpoint, verify=True)
TLS Configuration:
- Use TLS 1.3 or TLS 1.2 minimum
- Disable older protocols (SSLv3, TLS 1.0, TLS 1.1)
- Implement certificate pinning for critical services
- Validate certificates properly
8. Multi-Factor Authentication (MFA)
Enforce MFA for User Flows:
- Enable Conditional Access policies
- Require MFA for all users
- Use FIDO2 keys for high-privilege accounts
- Implement risk-based authentication
MFA Bypass Detection:
def validate_token_mfa(token_claims):
"""Verify MFA was used during authentication"""
amr_claim = token_claims.get('amr', [])
# Check authentication methods
mfa_methods = ['mfa', 'otp', 'sms', 'oath', 'phoneappnotification']
if not any(method in amr_claim for method in mfa_methods):
raise SecurityError("MFA required but not performed")
return True
Common Pitfalls and Solutions
1. Token Caching Issues
Problem: Requesting new tokens for every API call.
Impact: Poor performance, rate limiting, increased latency.
Solution:
from datetime import datetime, timedelta
class TokenCache:
def __init__(self):
self.token = None
self.expiry = None
def get_token(self, acquire_func):
# Check if token exists and is not expired
if self.token and self.expiry > datetime.utcnow() + timedelta(minutes=5):
return self.token
# Acquire new token
response = acquire_func()
self.token = response['access_token']
# Set expiry (with 5-minute buffer)
expires_in = response['expires_in']
self.expiry = datetime.utcnow() + timedelta(seconds=expires_in)
return self.token
# Usage
cache = TokenCache()
token = cache.get_token(acquire_token_function)
2. Hardcoded Secrets
Problem: Secrets committed to source control.
Solution:
# .gitignore
.env
appsettings.local.json
secrets.json
*.secret
*secret*
clientsecret*
Detection:
# Install git-secrets
git secrets --install
git secrets --register-aws
git secrets --register-azure
# Scan repository
git secrets --scan
# Pre-commit hook
git secrets --pre_commit_hook
3. Insufficient Error Handling
Problem: Generic error messages, no retry logic.
Solution (JavaScript):
class AuthenticationError extends Error {
constructor(message) {
super(message);
this.name = 'AuthenticationError';
}
}
class TokenExpiredError extends AuthenticationError {
constructor(message = 'Token has expired') {
super(message);
this.name = 'TokenExpiredError';
}
}
class InvalidCredentialsError extends AuthenticationError {
constructor(message = 'Invalid client credentials') {
super(message);
this.name = 'InvalidCredentialsError';
}
}
async function acquireTokenWithHandling() {
try {
return await acquireToken();
} catch (error) {
if (error.response) {
const status = error.response.status;
const data = await error.response.json();
if (status === 401) {
throw new InvalidCredentialsError('Invalid client credentials');
} else if (status === 400) {
const errorCode = data.error;
if (errorCode === 'invalid_grant') {
throw new TokenExpiredError('Token or password expired');
} else if (errorCode === 'invalid_client') {
throw new InvalidCredentialsError('Invalid client ID or secret');
} else {
throw new AuthenticationError(`Authentication failed: ${errorCode}`);
}
} else {
throw new AuthenticationError(`HTTP ${status}: ${data.error_description || data.error}`);
}
}
throw new AuthenticationError(`Unexpected error: ${error.message}`);
}
}
4. Missing Token Validation
Problem: Not validating tokens received from auth server.
Solution (JavaScript):
import { createRemoteJWKSet, jwtVerify } from 'jose';
async function validateAccessToken(token, expectedAudience, expectedIssuer) {
// JWKS endpoint URL
const jwksUrl = new URL(`${jwksUri} // from the OIDC discovery document`);
// Create remote JWK Set
const JWKS = createRemoteJWKSet(jwksUrl);
try {
// Verify and decode JWT
const { payload } = await jwtVerify(token, JWKS, {
issuer: expectedIssuer,
audience: expectedAudience,
algorithms: ['RS256', 'ES256']
});
return payload;
} catch (error) {
if (error.code === 'ERR_JWT_EXPIRED') {
throw new TokenExpiredError('Token has expired');
} else if (error.code === 'ERR_JWT_CLAIM_VALIDATION_FAILED') {
if (error.claim === 'aud') {
throw new AuthenticationError('Invalid token audience');
} else if (error.claim === 'iss') {
throw new AuthenticationError('Invalid token issuer');
}
}
throw new AuthenticationError(`Token validation failed: ${error.message}`);
}
}
// Usage
const payload = await validateAccessToken(
accessToken,
'api://your-application-id',
'https://login.microsoftonline.com/{tenant-id}/v2.0'
);
console.log('Token valid. User:', payload.sub);
Solution (Java):
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.interfaces.JWTVerifier;
import java.security.interfaces.RSAPublicKey;
public class TokenValidator {
private final String expectedIssuer;
private final String expectedAudience;
private final JWKSProvider jwksProvider;
public TokenValidator(String expectedIssuer, String expectedAudience) {
this.expectedIssuer = expectedIssuer;
this.expectedAudience = expectedAudience;
this.jwksProvider = new JWKSProvider(jwksUri); // jwksUri comes from the OIDC discovery document at
// {issuer}/.well-known/openid-configuration — never hard-code the JWKS path
}
public DecodedJWT validateToken(String token) throws Exception {
try {
// Decode header to get key ID
DecodedJWT unverified = JWT.decode(token);
String keyId = unverified.getKeyId();
// Get public key from JWKS
RSAPublicKey publicKey = jwksProvider.getPublicKey(keyId);
// Create algorithm and verifier
Algorithm algorithm = Algorithm.RSA256(publicKey, null);
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(expectedIssuer)
.withAudience(expectedAudience)
.build();
// Verify and decode
return verifier.verify(token);
} catch (com.auth0.jwt.exceptions.TokenExpiredException e) {
throw new TokenExpiredError("Token has expired");
} catch (com.auth0.jwt.exceptions.InvalidClaimException e) {
throw new AuthenticationError("Invalid token claims: " + e.getMessage());
} catch (Exception e) {
throw new AuthenticationError("Token validation failed: " + e.getMessage());
}
}
}
5. Using Deprecated Endpoints
Problem: Still using V1 OAuth endpoints.
Solution: Migrate to V2 endpoints:
// ❌ OLD - V1 endpoint (deprecated)
const V1_ENDPOINT = `https://login.microsoftonline.com/${tenantId}/oauth2/token`;
// ✅ NEW - V2 endpoint (recommended)
const V2_ENDPOINT = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
Migration Checklist:
- Update endpoint URLs to V2
- Change
resourceparameter toscope - Update token validation (issuer claim changes from
sts.windows.nettologin.microsoftonline.com/v2.0) - Test with V2 tokens
- Update application manifest (
requestedAccessTokenVersion: 2) - Update MSAL libraries to latest versions
Implementation Examples
Complete Python Example
"""
Complete API Authentication Example (Python)
Demonstrates client credentials flow with token caching
"""
import logging
import os
from datetime import datetime, timedelta
from typing import Dict, Optional
import requests
logger = logging.getLogger(__name__)
class TokenCache:
"""Simple token cache with expiry tracking"""
def __init__(self):
self.token: Optional[str] = None
self.expiry: Optional[datetime] = None
def set_token(self, token: str, expires_in: int):
self.token = token
# Buffer of 5 minutes before actual expiry
self.expiry = datetime.utcnow() + timedelta(seconds=expires_in - 300)
def get_token(self) -> Optional[str]:
if self.token and self.expiry > datetime.utcnow():
return self.token
return None
def clear(self):
self.token = None
self.expiry = None
class APIAuthenticator:
"""Handle API authentication with multiple methods"""
def __init__(
self,
tenant_id: str,
client_id: str,
client_secret: Optional[str] = None
):
self.tenant_id = tenant_id
self.client_id = client_id
self.client_secret = client_secret
self.token_cache = TokenCache()
self.v2_endpoint = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
def get_token_client_credentials(self) -> str:
"""Get token using client credentials flow"""
# Check cache first
cached_token = self.token_cache.get_token()
if cached_token:
logger.info("Using cached token")
return cached_token
logger.info("Acquiring new token with client credentials")
data = {
'grant_type': 'client_credentials',
'client_id': self.client_id,
'client_secret': self.client_secret,
'scope': f'{self.client_id}/.default'
}
try:
response = requests.post(self.v2_endpoint, data=data)
response.raise_for_status()
token_data = response.json()
access_token = token_data['access_token']
expires_in = token_data['expires_in']
# Cache the token
self.token_cache.set_token(access_token, expires_in)
logger.info(f"Token acquired successfully (expires in {expires_in}s)")
return access_token
except requests.HTTPError as e:
logger.error(f"Token acquisition failed: {e.response.text}")
raise
def get_token_ropc(self, username: str, password: str) -> str:
"""Get token using ROPC flow (not recommended for production)"""
logger.warning("Using ROPC flow - not recommended for production")
data = {
'grant_type': 'password',
'client_id': self.client_id,
'client_secret': self.client_secret,
'scope': f'{self.client_id}/.default',
'username': username,
'password': password
}
try:
response = requests.post(self.v2_endpoint, data=data)
response.raise_for_status()
token_data = response.json()
return token_data['access_token']
except requests.HTTPError as e:
logger.error(f"ROPC authentication failed: {e.response.text}")
raise
class APIClient:
"""API client with automatic authentication"""
def __init__(
self,
base_url: str,
authenticator: APIAuthenticator
):
self.base_url = base_url.rstrip('/')
self.authenticator = authenticator
self.session = requests.Session()
def _get_headers(self) -> Dict[str, str]:
"""Get headers with fresh access token"""
token = self.authenticator.get_token_client_credentials()
return {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
def get(self, endpoint: str, params: Optional[Dict] = None):
"""GET request with authentication"""
url = f"{self.base_url}/{endpoint.lstrip('/')}"
try:
response = self.session.get(
url,
headers=self._get_headers(),
params=params
)
response.raise_for_status()
return response.json()
except requests.HTTPError as e:
if e.response.status_code == 401:
# Token might be invalid, clear cache and retry
self.authenticator.token_cache.clear()
response = self.session.get(
url,
headers=self._get_headers(),
params=params
)
response.raise_for_status()
return response.json()
raise
def post(self, endpoint: str, data: Dict):
"""POST request with authentication"""
url = f"{self.base_url}/{endpoint.lstrip('/')}"
response = self.session.post(
url,
headers=self._get_headers(),
json=data
)
response.raise_for_status()
return response.json()
# Usage Example
if __name__ == "__main__":
# Load configuration from environment
TENANT_ID = os.environ.get('AZURE_TENANT_ID')
CLIENT_ID = os.environ.get('AZURE_CLIENT_ID')
CLIENT_SECRET = os.environ.get('AZURE_CLIENT_SECRET')
API_BASE_URL = os.environ.get('API_BASE_URL')
# Create authenticator
auth = APIAuthenticator(
tenant_id=TENANT_ID,
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET
)
# Create API client
api = APIClient(
base_url=API_BASE_URL,
authenticator=auth
)
# Make API calls
try:
# GET request
data = api.get('/api/data')
print("Data retrieved:", data)
# POST request
result = api.post('/api/process', {'key': 'value'})
print("Process result:", result)
except Exception as e:
logger.error(f"API call failed: {e}")
Environment Configuration
.env file (never commit this):
# Azure AD Configuration
AZURE_TENANT_ID=your-tenant-id-here
AZURE_CLIENT_ID=your-client-id-here
AZURE_CLIENT_SECRET=your-client-secret-here
# API Configuration
API_BASE_URL=https://api.example.com
Loading environment:
from dotenv import load_dotenv
import os
# Load .env file
load_dotenv()
# Access variables
tenant_id = os.environ.get('AZURE_TENANT_ID')
References
Official Microsoft Documentation
Microsoft Identity Platform Overview
OAuth 2.0 and OpenID Connect Protocols
MSAL Authentication Libraries
Resource Owner Password Credentials
Client Credentials Flow
OAuth 2.0 V1 vs V2 Endpoints
Security Best Practices
OAuth 2.0 Security Best Current Practice (RFC 9700)
OWASP API Security Top 10
Azure Security Best Practices
Libraries and Tools
MSAL.js (JavaScript/TypeScript)
MSAL Python
MSAL4J (Java)
MSAL.NET (C#)
Additional Resources
JWT Debugger
OAuth 2.0 Playground
Azure AD Token Reference
Conclusion
Choosing the right authentication method is crucial for API security:
- Use MSAL with PKCE for all user-facing applications
- Use Client Credentials for service-to-service communication
- Avoid ROPC except for temporary testing scenarios
- Implement proper token management with caching and refresh logic
- Store secrets securely using vault services
- Monitor and audit all authentication activity
- Rotate credentials regularly following a defined schedule
- Always use V2 endpoints for new implementations
By following the patterns and practices in this guide, you can implement secure, scalable API authentication that protects your resources while providing a smooth user experience.
Document Version: 1.0 Last Updated: November 2025 Maintainer: Technical Documentation Team