Security Guide

API Authentication Methods: A Complete Guide

OAuth 2.0 flows, MSAL, client credentials, service accounts, token versions and JWT validation — with the failure modes that cause real breaches.

  • OAuth 2.0
  • JWT
  • MSAL
  • Client Credentials
  • Service Accounts
  • Token Validation

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

Decision tree: interactive users get authorization code with PKCE; server-to-server calls get client credentials; testing environments may use ROPC only temporarily, never in production; other automated workflows use a service account with a federated or certificate credential. Authentication Method Decision Tree Are users interacting? YES NO ✓ Use MSAL Authorization Code with PKCE Server-to-server? YES NO ✓ Client Credentials Service authentication Testing/Development? YES NO ⚠ ROPC (temporary) Not for production ✓ Service Account Federated / certificate Legend: ✓ Recommended ⚠ Use with caution

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

  1. Token Storage

    • Browser: Use sessionStorage or secure cookies (HttpOnly)
    • Mobile: Use platform keychain/keystore
    • Keep access tokens in memory, or behind a backend-for-frontend holding them in HttpOnly cookies. sessionStorage limits persistence but gives no protection against XSS — treat it exactly like localStorage
  2. Token Refresh

    • Use acquireTokenSilent() to refresh automatically
    • Implement retry logic for transient failures
    • Handle InteractionRequiredAuthError appropriately
  3. Error Handling

    • Catch and handle all MSAL exceptions
    • Implement graceful fallback to interactive auth
    • Log errors (excluding PII)
  4. 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:

  1. Short-term: Implement client credentials for service accounts
  2. Medium-term: Migrate to authorization code flow with PKCE for user-facing apps
  3. 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:

  1. 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.
  2. Certificate credentials — the application signs a short-lived assertion with a private key that never leaves the caller.
  3. 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.

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 older repo:{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:GetWebIdentityToken for 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 GetWebIdentityToken produce one; AssumeRole does 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:

  1. 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"
    }
  ]
}
  1. 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 upn or preferred_username claims (no user context)
  • sub and oid identify the service principal
  • roles contains 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

  1. 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
  2. Secret Rotation

    • Rotate secrets every 30-90 days
    • Implement zero-downtime rotation (have 2 secrets active during transition)
    • Automate rotation process
  3. Scope Limitation

    • Request only necessary permissions
    • Use multiple service principals for different purposes
    • Implement principle of least privilege
  4. Monitoring

    • Log all token acquisitions
    • Monitor for unusual patterns
    • Alert on failed authentication attempts
    • Audit service principal usage regularly
  5. 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:

  1. Add HTTP action
  2. Select Authentication type: Active Directory OAuth
  3. Fill in parameters above
  4. 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

  1. 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
  2. Least Privilege

    • Grant only necessary permissions
    • Create separate service accounts per workflow/purpose
    • Review permissions quarterly
  3. Monitoring and Auditing

    • Log all service account activity
    • Set up alerts for unusual patterns
    • Monitor token acquisition failures
    • Track API usage by service account
  4. Token Caching

    • Cache tokens for their lifetime (usually 1 hour)
    • Implement refresh logic before expiry
    • Handle token expiration gracefully
  5. 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:

  1. Update application manifest: requestedAccessTokenVersion: 2
  2. Update token validation to accept V2 issuer
  3. Update claim extraction (use preferred_username instead of unique_name)
  4. Test thoroughly in non-production environments
  5. 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 .env files 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 Authorization header (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 resource parameter to scope
  • Update token validation (issuer claim changes from sts.windows.net to login.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

  1. Microsoft Identity Platform Overview

  2. OAuth 2.0 and OpenID Connect Protocols

  3. MSAL Authentication Libraries

  4. Resource Owner Password Credentials

  5. Client Credentials Flow

  6. OAuth 2.0 V1 vs V2 Endpoints

Security Best Practices

  1. OAuth 2.0 Security Best Current Practice (RFC 9700)

  2. OWASP API Security Top 10

  3. Azure Security Best Practices

Libraries and Tools

  1. MSAL.js (JavaScript/TypeScript)

  2. MSAL Python

  3. MSAL4J (Java)

  4. MSAL.NET (C#)

Additional Resources

  1. JWT Debugger

  2. OAuth 2.0 Playground

  3. 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

Next step

Talk to a security consultant

Tell us what you are building, defending or certifying. We will tell you plainly what we would test first, what it takes, and whether you need us at all.