Azure Identity

OIDC and OAuth2 on Entra ID: Choosing the Right Flow (Auth Code, PKCE, Client Credentials)

Every modern app eventually has to answer two questions: who is this user and is this caller allowed to touch that API. On Microsoft Entra ID (formerly Azure AD) the answer is a small family of standard protocols — OAuth2 for authorization and OpenID Connect (OIDC) for sign-in — and the most common mistake is reaching for the wrong flow: a browser secret in a single-page app, a daemon that pops a login screen no human will see, a mobile app handed a client secret anyone can extract from the binary. Each is a real flow used in the wrong place, and each is a security incident waiting to happen.

This article is the mental-model layer. You will not memorise every parameter of /authorize and /token; you will learn the shape of each flow, the one job it does, and a decision table that takes you from “I am building a ___” to “use the ___ flow.” We cover the five flows you meet on Entra ID — Authorization Code, Authorization Code with PKCE, Client Credentials, Device Code, and On-Behalf-Of — plus the two legacy flows (Implicit and Resource Owner Password Credentials) you should recognise only to refuse, registering the apps with both az CLI and Bicep.

By the end you will know the difference between an ID token and an access token (people conflate these constantly, and it causes real bugs), why PKCE replaced the old browser secret, what a scope versus an app role is, and how to read the common token errors (AADSTS65001, AADSTS70011, AADSTS700016, AADSTS50011) well enough to fix them without a support ticket. The goal: never again guess which flow to use, or be surprised by a redirect-URI or consent error at 5pm on launch day.

What problem this solves

In the bad old days every app did its own login — a username/password form, a session cookie, a users table of hashed passwords, a bespoke “is this allowed” check — which leaks credentials across every app, can’t do MFA or Conditional Access, and re-implements security-critical code everywhere. OAuth2 and OIDC exist so your app never sees the user’s password and never stores credentials for the services it calls: the user authenticates once against Entra ID, your app receives signed tokens, the API trusts them, and passwords, MFA, and risk evaluation all stay inside Entra ID where a dedicated team hardens them.

The pain when you get the flow wrong is concrete. A mobile app that embeds a client secret ships it to every attacker who downloads the app. A background job on an interactive flow simply cannot run — no human types a password at 3am, so the nightly sync silently never starts. An API that accepts an ID token where it should require an access token authorises calls it should reject. None of these are exotic; they are the mistakes teams make on their first Entra ID integration, every time. Anyone building on Entra ID hits them — web app, SPA, mobile, daemon, API-calling-API, or keyboardless device — and each maps to exactly one flow, a five-minute decision up front that saves a five-day rewrite.

Learning objectives

By the end you can:

Prerequisites & where this fits

You should be comfortable with HTTP redirects (a browser follows 302s), know roughly what a JWT (a base64url-encoded, signed blob of claims) is, and have an Entra ID tenant you can register apps in (a free Microsoft 365 developer tenant works). You do not need cryptography or the OAuth2 RFCs — that is the point of this layer.

This is the foundational concept article in the Identity track, upstream of the deep-dives. Once you know which flow you need, Building a Secure OIDC Confidential Client in Entra ID takes the Authorization Code path to production depth, and Mastering Entra ID Tokens: App Roles, Group Claims, and the OAuth2 On-Behalf-Of Flow for APIs goes deep on token contents and the API-to-API case. When your app calls Azure resources rather than custom APIs, Managed Identities Deep Dive beats a client secret. It assumes the Azure resource hierarchy only insofar as an app registration lives in a tenant.

A quick map of who owns what during an integration, so you call the right person when something 403s:

Layer What lives here Who usually owns it What it can break
Entra ID tenant Users, app registrations, consent, Conditional Access Identity / security team Consent prompts, blocked sign-ins, MFA
App registration Redirect URIs, secrets, API permissions, app roles App owner + identity Redirect mismatch, expired secret, missing scope
Your client app Which flow, MSAL config, token caching App / dev team Wrong flow, token misuse, no token refresh
The protected API Token validation (audience, scope, signature) API owner Accepting wrong token, rejecting valid ones
Microsoft Graph / downstream The resource you actually want to call Microsoft / resource owner Insufficient permission, admin-consent-required

Core concepts

Five mental models make every flow obvious.

OAuth2 is about authorization; OIDC is about authentication. OAuth2 answers “is this app allowed to access that resource on someone’s behalf” — it issues an access token the app presents to an API, saying nothing about who the user is. OpenID Connect is a thin layer on top of OAuth2 that adds sign-in, issuing an ID token that tells your app who just logged in. Need only to call an API? Pure OAuth2. Need the user’s identity? OIDC. Most web apps use both — OIDC to sign the user in, OAuth2 to get tokens for the APIs they call.

There are three token types, and they are not interchangeable. An ID token is for your app — it proves a user signed in (claims like name and oid). An access token is for an API — sent in the Authorization: Bearer header for the API to validate. A refresh token silently gets new access tokens when the short-lived one expires. The rule: ID token = “who logged in”, access token = “what I’m allowed to call” — sending an ID token to an API is the most common token bug.

Confidential clients can keep a secret; public clients cannot. A confidential client runs where the user never sees the bytes (web server, daemon), so it can hold a client secret or certificate. A public client runs on the user’s device (SPA, mobile, desktop), where any shipped secret can be extracted, so it uses PKCE instead. Misclassifying a SPA as confidential is the root cause of the “secret leaked in the browser” class of incidents.

The flow is just how your app gets the token. Every flow is a choreography between your app, the browser, and Entra ID’s two endpoints — /authorize (sign-in and consent) and /token (exchange a code for tokens). Flows differ in whether a human is present, whether a browser is involved, and whether the app can hold a secret. You almost never implement it by hand: MSAL (Microsoft Authentication Library) does it in every language; your job is to pick the flow and configure the registration.

Permissions come in two shapes. A delegated permission (a scope, e.g. User.Read) means the app acts as the signed-in user. An application permission (an app role, e.g. User.Read.All) means the app acts as itself, no user — used by daemons, and almost always needs admin consent. Delegated permissions ride on user-present flows; application permissions ride on Client Credentials. Mixing them up is a top-three support question.

OAuth2 vs OIDC: the one distinction that fixes half the bugs

People use “OAuth” and “login” interchangeably, then wonder why their API accepts a token it shouldn’t. OAuth2 is delegated authorization — a user (or app) grants your app permission to call a resource, and Entra ID issues an access token scoped to it. OIDC adds federated authentication — sign this user in and tell me who they are, answered with an ID token. They share the same /authorize and /token endpoints; the difference is what you ask for and what you get back.

The practical tell is the scopes you request. Ask for openid profile (the OIDC scopes) and you get an ID token — sign-in. Ask for an API scope like https://graph.microsoft.com/User.Read and you get an access token. Most web apps request both at once. The comparison that ends the confusion:

Aspect OAuth2 OpenID Connect (OIDC)
Question it answers “Can this app call that API?” “Who is this user?”
What it issues Access token (+ refresh token) ID token (+ access/refresh)
Token audience The API you’ll call Your app (the client)
Trigger scopes API scopes (.../User.Read) openid, profile, email
Built for Authorization / API access Sign-in / single sign-on
Validated by The protected API Your app (the relying party)
Without it you’d Re-implement API auth per service Build your own password login

A second distinction trips people — the ID token is for your own app, the access token is for the API; backwards, you either reject valid users or accept calls you should refuse:

If you have a… Send it to… Validate it in… Common misuse
ID token Nowhere (it’s for you) Your app Sending it to an API as a bearer token
Access token The API, as Bearer The API Reading user claims from it in your app
Refresh token Only Entra ID /token Never validate; store securely Treating it as a session token

The Authorization Code flow (the workhorse)

For a server-side web app that signs users in, the Authorization Code flow is almost always right — it keeps tokens off the browser and lets a confidential client use its secret. The choreography:

  1. The user clicks “Sign in.” Your app redirects the browser to /authorize with your client_id, redirect_uri, scopes, and response_type=code.
  2. Entra ID authenticates the user (password, MFA, Conditional Access — all on its side), shows consent the first time, then redirects back to your redirect_uri with a short-lived, one-time authorization code.
  3. Your app’s back end calls /token server to server, presenting the code plus its client secret, and receives the ID token, access token, and refresh token — the browser never sees these.
  4. Your app establishes a session and uses the access token to call APIs; the refresh token silently renews it.

The crucial property is step 3: the code is exchanged on the back channel, so even though it travelled through the browser, the tokens never do and the secret never leaves your server — which is why this flow suits confidential clients. The parameters that matter:

Parameter Endpoint Purpose Gotcha
client_id /authorize & /token Identifies your app registration Wrong/typo → AADSTS700016 (app not found)
redirect_uri /authorize & /token Where the code is returned Must exactly match a registered URI or AADSTS50011
scope /authorize What you’re asking for Include offline_access to get a refresh token
response_type=code /authorize Selects the code flow token/id_token here = legacy Implicit; avoid
code /token The one-time code to redeem Single-use, short-lived (minutes)
client_secret /token Proves the confidential client Never put this in a browser/SPA/mobile app

You almost never write this by hand — in ASP.NET Core it is AddMicrosoftIdentityWebApp, in Node MSAL Node, in Python msal.

PKCE: why the browser secret had to go

A single-page or mobile app runs on the user’s device, so it is a public client — it cannot keep a secret, because anyone can open dev-tools or decompile the app. The original OAuth2 answer, the Implicit flow, returned the access token in the browser URL fragment — leaking tokens into history, referer headers, and logs, with no safe way to deliver a refresh token, and is now discouraged for all new apps.

The modern answer is Authorization Code with PKCE (Proof Key for Code Exchange, “pixy”), which lets a public client use the same robust code flow without a secret, by proving at redemption that it is the app that started the flow:

  1. Before redirecting, the app generates a random secret — the code verifier — and a SHA-256 hash of it, the code challenge.
  2. It sends the code challenge (the hash) to /authorize; Entra ID remembers it alongside the code it issues.
  3. At /token the app sends the original code verifier (plaintext); Entra ID hashes it and checks it matches the challenge.

Only the app that generated the verifier can produce it, so an attacker who steals the code from the browser cannot redeem it — and no static secret is shipped anywhere. PKCE is now recommended for every Authorization Code flow, even confidential clients. The comparison that closes the case:

Property Implicit (legacy) Auth Code + PKCE (modern)
Where the token lands Browser URL fragment (leaky) Redeemed at /token, off the URL
Static client secret None (but token exposed) None — uses dynamic verifier
Refresh tokens Not safely deliverable Yes (with offline_access)
Protects a stolen code N/A (no code) Yes — code is useless without verifier
Entra ID stance Discouraged for new apps Recommended default
Use for Nothing new SPAs, mobile, desktop, and more

You do not implement PKCE by hand — MSAL.js (SPAs) and MSAL (mobile/desktop) generate the verifier and challenge automatically. You only register the app as the correct platform type (SPA or public client) and request offline_access for silent refresh.

Client Credentials: when there is no user

Some workloads have no human in the loop — a nightly sync, a webhook processor, a service reading all users from Graph on a schedule. No browser, no password prompt, nobody to act “on behalf of.” These use the Client Credentials flow: the app authenticates as itself with its own credential (a secret or, better, a certificate or federated credential) directly at /token, receiving an access token tied to its application permissions (app roles), not to any user.

The defining traits: confidential-client only; application permissions that almost always require admin consent; and a token with no user claims — no name, no oid, because no user signed in. The classic failure is confusing the permission shapes: a daemon granted the delegated User.Read gets “insufficient privileges” because there is no user to delegate from; it needs the application User.Read.All.

Trait Client Credentials A user flow (Auth Code)
User present? No Yes
Client type Confidential only Confidential or public
Credential Secret / cert / federated credential Secret (confidential) or PKCE (public)
Permission shape Application (app role) Delegated (scope)
Consent Admin consent, once User (or admin) consent
Token has user claims? No Yes (name, oid, …)
Scope value requested https://graph.microsoft.com/.default Specific scopes (User.Read)

When the workload runs inside Azure (VM, App Service, Function, AKS pod), prefer a managed identity over a client secret — Azure manages the credential, with nothing to leak or rotate — and for CI/CD or cross-cloud, a federated credential. The full treatment is in Managed Identities Deep Dive; the rule of thumb is use a stored secret only when nothing better fits.

Device Code and On-Behalf-Of: the two you’ll meet next

Two more flows round out the set. The Device Code flow is for input-constrained devices — a smart TV, an IoT box, a headless CLI — where typing a username, password, and MFA code is impractical. The device shows a short code and a URL (https://microsoft.com/devicelogin); the user opens it on a phone or laptop, enters the code, and signs in there while the device polls /token. The az CLI uses this with az login --use-device-code. It is a public-client flow, right only when a normal browser flow isn’t available.

The On-Behalf-Of (OBO) flow solves a different problem: an API that must call another API as the original user. A user calls your middle-tier API with an access token; that API needs to call Graph as that same user, not as itself. OBO exchanges the incoming user token for a new downstream token, preserving the user’s identity through the chain — the standard pattern for API-to-API delegation (see Mastering Entra ID Tokens: App Roles, Group Claims, and the OAuth2 On-Behalf-Of Flow for APIs). The two legacy flows to recognise only to avoid are Implicit (superseded by PKCE) and ROPC (the app collects the user’s actual password, defeating OAuth2 and unable to do MFA/Conditional Access).

The master decision table

The table to bookmark — find your app type on the left, use the flow on the right:

You are building… Client type Use this flow Why
Server-rendered web app (sign-in) Confidential Auth Code (+PKCE) Has a server; tokens stay off the browser
Single-page app (React/Angular/Vue) Public Auth Code + PKCE No secret possible; PKCE protects the code
Mobile / desktop app Public Auth Code + PKCE Same as SPA; MSAL handles PKCE
Daemon / background job / cron Confidential Client Credentials No user present; acts as itself
Web API calling another API as the user Confidential On-Behalf-Of Preserves the user’s identity downstream
CLI / TV / IoT (no keyboard) Public Device Code User signs in on a second device
Anything new in the browser Never Implicit Leaks tokens; PKCE replaces it
Anything that wants the raw password Never ROPC Defeats OAuth2; no MFA/Conditional Access

Reading the table the other way: Auth Code (+PKCE) and On-Behalf-Of are delegated, user-present, and issue refresh tokens; Client Credentials is app-only with no refresh token (re-auth is cheap); Device Code is the public-client fallback when no browser is available; and Implicit and ROPC are the two to avoid. Prefer a managed identity or federated credential over a stored secret for any Client Credentials workload.

Architecture at a glance

Walk the diagram left to right. The user’s browser (the only place a human and a password meet) is redirected to Entra ID, which authenticates the user, shows consent once, and returns a short-lived authorization code to your redirect URI — harmless on its own. The decisive hop is the back channel: your web app server (a confidential client) calls /token server to server with its client secret (or, for a public SPA, the PKCE verifier) and receives the ID token (who logged in) and an access token, which the app presents as a Bearer credential to the protected API — Graph or your own Web API — that validates signature, audience, and scope before serving the call. The four numbered badges mark where the path breaks; the legend narrates each as symptom, confirm, and fix.

Left-to-right OAuth2 / OpenID Connect Authorization Code with PKCE flow on Microsoft Entra ID. Zone 1, the user in a browser, is the only place a human and password meet; it is redirected and never sees the app's secret. Zone 2 is Entra ID, the identity provider, with a /authorize endpoint (sign-in, MFA, Conditional Access, one-time consent) and a /token endpoint that exchanges the authorization code plus client secret or PKCE verifier for tokens, showing the verifier-hash check that lets public clients skip a secret. Zone 3 is the application tier: a confidential web-app server that holds a client secret and redeems the code on the back channel, beside a public SPA that uses MSAL.js and PKCE instead; it receives the ID token (who logged in) and the access token (what it may call). Zone 4 is the protected resource: Microsoft Graph and a custom Web API that validate the access token's signature, audience, and scope before serving the request, with Azure Key Vault holding the app's secret or certificate. Flows chain the zones: browser to Entra ID for sign-in, Entra ID back with the one-time code, app to Entra ID to redeem the code, and app to API presenting the access token as a Bearer credential. Four numbered badges mark the failure points: (1) redirect-URI mismatch causing AADSTS50011; (2) unconsented delegated permission causing AADSTS65001; (3) expired or wrong client secret causing AADSTS7000215; (4) audience or scope mismatch at the API causing a 401. A legend narrates each badge as symptom, confirm step, and fix.

Real-world scenario

Northwind Retail runs an online store as three apps in one Entra ID tenant: a customer-facing React storefront (SPA), an ASP.NET Core admin portal for staff, and a nightly inventory-sync job writing supplier data into the catalog via an internal Web API. They first shipped by reusing one app registration for all three with one client secret, because “it worked in the demo.” It worked until it didn’t.

The first failure landed on launch day. The storefront had response_type=token (Implicit) and the shared secret baked into the bundle — a junior dev had copied the admin portal’s settings. A security scan flagged the secret in the JavaScript within an hour, with access tokens visible in browser history. The fix was structural: split into three app registrations and register the storefront as a SPA platform using Authorization Code with PKCE through MSAL.js with no secret.

The second failure was the nightly job, which never ran. It had the delegated Catalog.ReadWrite scope and an interactive sign-in — so at 2am, with no human to authenticate, MSAL threw and the sync failed silently for four nights before anyone noticed empty shelves. The team moved it to Client Credentials with the application permission Catalog.ReadWrite.All and admin consent, and replaced the secret with the Function’s managed identity.

The admin portal was the one they got right: a confidential ASP.NET Core app using Authorization Code (PKCE for defence in depth), the client secret in Key Vault, and staff signed in with MFA via Conditional Access. When it later needed to call Graph as the signed-in admin to read group membership — the On-Behalf-Of pattern — adding it was a config change, not a rewrite, because the foundations were correct. The lesson they wrote on the wall: one app registration per app, pick the flow from the table before writing code, and never ship a secret to a browser.

Advantages and disadvantages

Using Entra ID’s standard flows instead of rolling your own auth is almost always right, but be honest about the trade-offs:

Advantages Disadvantages
Your app never sees the user’s password A learning curve: flows, tokens, consent
MFA, Conditional Access, risk policies come free More moving parts than a password form
Tokens are short-lived and scoped (blast-radius small) Misconfiguration (wrong flow) has real security cost
Standard protocols — MSAL does the hard parts Redirect-URI / consent errors are confusing first time
Single sign-on across all your apps Token lifetimes and refresh add complexity
Centralised audit of every sign-in Dependent on Entra ID availability
Secrets can be eliminated (MI / federated creds) Secret rotation needed when you do use secrets

The disadvantages are mostly one-time costs — the learning curve and per-app configuration errors are paid once, and the troubleshooting section preempts them — while the advantages compound forever. The one you cannot wave away is that choosing the wrong flow has a security cost, which is why the decision table is the most important artifact here: the only real decision is which flow, not whether.

Hands-on lab

This lab registers a confidential web app, adds a secret and a Graph permission, fetches an app-only token to inspect it, and tears down — all free. You need the az CLI logged in to a tenant where you can register apps.

1. Register a confidential web app (Authorization Code flow). Create the registration with a web redirect URI:

az ad app create \
  --display-name "kv-demo-webapp" \
  --sign-in-audience AzureADMyOrg \
  --web-redirect-uris "https://localhost:5001/signin-oidc"
# Note the appId (client_id) it prints
APP_ID=$(az ad app list --display-name "kv-demo-webapp" --query "[0].appId" -o tsv)
echo "client_id = $APP_ID"

2. Add a client secret (confidential clients only). Generate a secret and capture it once — you cannot read it again:

az ad app credential reset --id "$APP_ID" --display-name "lab-secret" \
  --years 1 --query "{client_id:appId, secret:password, tenant:tenant}" -o json
# Copy the 'secret' value now; it is shown only this once.

3. Add a delegated Graph permission (User.Read) and consent. User.Read is the well-known e1fe6dd8-ba31-4d61-89e7-88639da4683d; the Graph resource app ID is 00000003-0000-0000-c000-000000000000:

az ad app permission add --id "$APP_ID" \
  --api 00000003-0000-0000-c000-000000000000 \
  --api-permissions e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope
# Grant consent (User.Read is a low-privilege delegated scope)
az ad app permission grant --id "$APP_ID" \
  --api 00000003-0000-0000-c000-000000000000 \
  --scope "User.Read"

4. Get an app-only token via Client Credentials to see a no-user token. Reset the web app’s secret into a variable, then redeem it directly at /token:

SECRET=$(az ad app credential reset --id "$APP_ID" --query password -o tsv)
TENANT=$(az account show --query tenantId -o tsv)

# Client Credentials grant: the app authenticates AS ITSELF, scope=.default
curl -s -X POST "https://login.microsoftonline.com/$TENANT/oauth2/v2.0/token" \
  -d "client_id=$APP_ID" -d "client_secret=$SECRET" \
  -d "scope=https://graph.microsoft.com/.default" \
  -d "grant_type=client_credentials" | python3 -m json.tool

The response has an access_token and "token_type": "Bearer" but no id_token — because no user signed in. Paste the access_token into the Microsoft-run decoder at https://jwt.ms and note the aud (audience) and the absence of the name/scp a user token would carry.

5. Equivalent Bicep for the web app registration (via the Microsoft.Graph provider, which needs the Graph extension):

extension microsoftGraphV1

resource webApp 'Microsoft.Graph/applications@v1.0' = {
  displayName: 'kv-demo-webapp'
  signInAudience: 'AzureADMyOrg'
  web: {
    redirectUris: [ 'https://localhost:5001/signin-oidc' ]
    implicitGrantSettings: {
      enableIdTokenIssuance: false   // keep Implicit OFF; use Auth Code + PKCE
      enableAccessTokenIssuance: false
    }
  }
  requiredResourceAccess: [
    {
      resourceAppId: '00000003-0000-0000-c000-000000000000' // Microsoft Graph
      resourceAccess: [
        { id: 'e1fe6dd8-ba31-4d61-89e7-88639da4683d', type: 'Scope' } // User.Read (delegated)
      ]
    }
  ]
}

6. Teardown. Remove the registration so nothing lingers:

az ad app delete --id "$APP_ID"

Common mistakes & troubleshooting

The failures every team hits on a first Entra ID integration — symptom → root cause → confirm → fix. The AADSTS codes are real and appear on the sign-in error page and in the Entra admin center → Sign-in logs.

# Symptom / error Root cause How to confirm Fix
1 AADSTS50011 “redirect URI does not match” The redirect_uri your app sends isn’t exactly a registered one (scheme, port, trailing slash, http vs https all matter) Compare the redirect_uri in the failing request URL to the app registration’s Authentication blade Add the exact URI to the registration; match case and trailing slash
2 AADSTS65001 “user or admin has not consented” A requested delegated permission was never consented Sign-in logs show the failed scope; check API permissions for “Not granted” Grant consent (az ad app permission grant) or click Grant admin consent
3 AADSTS70011 “invalid scope” Malformed scope string, or requesting a v1 resource on the v2 endpoint Inspect the scope parameter sent to /authorize Use full v2 scope (https://graph.microsoft.com/User.Read) or .default for app-only
4 AADSTS700016 “application not found in directory” Wrong client_id, or app registered in a different tenant Verify client_id and the /authorize tenant segment match the registration Use the correct client_id and tenant ID//common as appropriate
5 AADSTS7000215 “invalid client secret provided” Secret expired, mistyped, or you pasted the secret ID instead of its value Check the secret’s expiry on Certificates & secrets; confirm you stored the value Generate a new secret; store the value (not the ID); rotate before expiry
6 Daemon gets “insufficient privileges” despite a permission Granted a delegated scope to an app with no user API permissions shows it as Delegated, not Application Add the Application permission and Grant admin consent
7 API rejects a valid-looking token (401) Audience mismatch — token’s aud is for a different resource (e.g. you sent an ID token) Decode the token at jwt.ms; check aud and scp/roles Request a token for this API’s scope; send the access token, not the ID token
8 Secret leaked / token in browser A public client (SPA/mobile) shipped a client secret or used Implicit Search the JS bundle for the secret; check response_type Re-register as SPA/public; switch to Auth Code + PKCE; remove the secret
9 User prompted to sign in on every request No refresh token / token cache; offline_access not requested Check requested scopes; check MSAL token-cache config Request offline_access; let MSAL cache and silently refresh
10 AADSTS50058 / silent sign-in fails Silent token request with no existing session Happens on first load or after cache clears Fall back to an interactive sign-in when silent acquisition fails

Two reading rules save the most time. Always decode the token at jwt.ms when an API rejects it — 9 of 10 “my token doesn’t work” issues are a wrong aud or missing scope, visible in two seconds. And read the AADSTS code, not the generic message — it pins the cause where the human-readable text is vague, on the error page and in the sign-in logs:

# List recent failed interactive sign-ins with their AADSTS error code
az rest --method get \
  --url "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$top=20&\$filter=status/errorCode ne 0" \
  --query "value[].{app:appDisplayName, code:status.errorCode, reason:status.failureReason}" -o table

Best practices

Security notes

The flows are the security control, but configuration is where they’re won or lost. Treat the client secret as the crown jewel of a confidential client — never in source control or a browser, ideally replaced by a managed identity or certificate/federated credential so there is no shared secret at all; when you must use one, store it in Key Vault (see Azure Key Vault: Secrets, Keys and Certificates Done Right) and reference it at runtime.

Apply least privilege and remember application permissions are powerful: User.Read.All reads every user with no user context, which is why they require admin consent. Governing consent is its own discipline — attackers use illicit-consent phishing to trick users into granting a malicious app real permissions, so admins should restrict who can consent and review grants (see Governing OAuth Consent and Application Permissions in Entra ID).

Lock the perimeter: register exact redirect URIs (a wildcard or stray localhost is an open-redirect risk), keep Implicit and ROPC disabled, and enforce Conditional Access so even a valid token only flows when device, location, and risk are acceptable — see Designing Conditional Access at Scale. Validate audience and signature on every API request, and prefer short-lived access tokens with refresh — a leaked 60-minute token is a small window, a leaked long-lived one is a breach.

Cost & sizing

The protocols themselves are free. App registrations, the /authorize and /token endpoints, the flows, sign-ins, and token issuance carry no per-transaction charge on the basic Entra ID Free tier — register unlimited apps and authenticate at scale without paying per login. What can cost money are the surrounding capabilities, not the flows:

Item Cost driver Rough figure Notes
Flows, token issuance & app registrations Free No per-sign-in, per-token, or per-app charge
Entra ID Free (basic SSO, app reg) ₹0 / $0 Included with Azure / M365
Conditional Access, risk policies Entra ID P1/P2 licence ~₹500–800 / ~$6–9 per user/mo Per-user, only if you need CA / Identity Protection
Key Vault (storing client secrets) Per operation + per cert ~₹0.25 / ~$0.03 per 10k ops Tiny; standard tier suffices
External ID (customer sign-ins / CIAM) Monthly active users First tier free, then per-MAU Only for customer-facing identity
Managed identity (instead of secret) Free Removes secret-rotation cost entirely

The only “sizing” question is licensing, per-user not per-flow: Conditional Access and Identity Protection need Entra ID P1/P2 for the users they apply to, and customer-facing External ID is priced per monthly active user. Everything else works on the free tier — and the cheapest and most secure move, a managed identity over a stored secret, also has no licence cost.

Interview & exam questions

These map to SC-900 (Security, Compliance & Identity Fundamentals), AZ-204 (Developing Solutions for Azure), and SC-300 (Identity and Access Administrator).

Q1. What is the difference between OAuth2 and OpenID Connect? OAuth2 is an authorization framework that issues access tokens to call an API on a user’s or its own behalf. OIDC is a thin authentication layer on top that adds sign-in, issuing an ID token telling your app who the user is. Most web apps use both at once.

Q2. ID token vs access token — who consumes each? The ID token is for your app and proves a user signed in. The access token is for an API, presented as a Bearer credential for the API to validate. Sending an ID token to an API, or treating an access token as proof of identity, is a common bug.

Q3. Why was PKCE introduced, and who needs it? PKCE lets public clients (SPAs, mobile, desktop) use the Authorization Code flow without a client secret, proving at redemption that they started the flow. It replaced the leaky Implicit flow and is now recommended for all code flows.

Q4. When do you use the Client Credentials flow? When there is no user — a daemon or background job calling an API as itself. It is confidential-client only, uses application permissions that usually need admin consent, and the token has no user claims.

Q5. A SPA developer asks for a client secret to put in the React code. What do you say? No — a SPA is a public client and any shipped secret is extractable. Register it as a SPA platform and use Authorization Code with PKCE via MSAL.js, which needs no secret.

Q6. Your nightly job can’t read users despite having a User.Read permission. Why? User.Read is a delegated scope and a daemon has no signed-in user. It needs the application permission User.Read.All with admin consent, via Client Credentials.

Q7. What does AADSTS50011 mean and how do you fix it? The redirect_uri doesn’t exactly match a registered URI — scheme, port, and trailing slash all count. Add the exact URI to the app registration’s Authentication blade.

Q8. When would you use the Device Code flow? On input-constrained devices (smart TV, IoT, headless CLI) where typing credentials is impractical: the device shows a code and URL, the user signs in on a phone, the device polls for tokens. az login --use-device-code uses it.

Q9. What is the On-Behalf-Of flow for? For an API that must call another API as the original user — the middle tier exchanges the incoming user token for a new downstream token, preserving the user’s identity through the chain.

Q10. Why avoid the ROPC (password) flow? Because the app collects the user’s actual password, defeating OAuth2’s promise that your app never sees credentials, and it cannot do MFA or Conditional Access. Use it only for narrow legacy automation, if ever.

Quick check

  1. You’re building a single-page React app that signs users in and calls Microsoft Graph. Which flow?
  2. Which token do you send to an API as a Bearer credential — ID token or access token?
  3. A background job with no user needs to read all users in the tenant. Which flow and which shape of permission?
  4. PKCE replaced which legacy flow, and what does it let a public client avoid shipping?
  5. You get AADSTS65001 on sign-in. What’s the cause and the fix?

Answers

  1. Authorization Code with PKCE. A SPA is a public client; PKCE lets it use the code flow with no secret, and MSAL.js handles it.
  2. The access token. The ID token is for your own app; it is never sent to an API.
  3. Client Credentials flow with an application permission (e.g. User.Read.All) and admin consent — there is no user to delegate from.
  4. PKCE replaced the Implicit flow and lets a public client avoid shipping a static client secret, using a dynamic verifier instead.
  5. A requested delegated permission was never consented. Grant consent or click Grant admin consent on the API permissions blade.

Glossary

Next steps

Entra IDOAuth2OpenID ConnectAuthenticationPKCEMSALApp RegistrationIdentity
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading