🛸 Hitchhiker's Guide — Phase 03: Entra ID, OAuth2 / OIDC / JWT
Read this if: you've clicked "Login with Microsoft" and wired up an SDK, but couldn't draw the auth-code + PKCE flow or list — in order — what an API checks on the JWT it receives. This is the compressed tour the WARMUP derives slowly. Skim it, read the WARMUP, then build the engine.
0. The 30-second mental model
Identity is the perimeter; the JWT is the boundary. Entra ID mints a short-lived, signed,
audience-scoped token; your service validates it on every request before doing anything. A
login is: client → /authorize (user signs in, get a one-time code) → /token (swap code
- PKCE proof for tokens) → call the API with
Bearer <access_token>→ the API validates the JWT. The whole security of it is one ordered checklist: signature first, theniss, thenaud, then time, then scope/role — fail closed on any miss.
One sentence to tattoo: a valid signature proves the token is real; only the aud check
proves it was minted for YOU — skip aud and you're a confused deputy.
1. Pick the grant in one breath
user in a browser / native app → authorization code + PKCE (access + ID + refresh)
daemon, cron, service-to-service → client credentials (access w/ `roles`, no user)
middle-tier API calling an API → on-behalf-of (OBO) (downstream token AS the user)
TV / CLI / IoT (no browser) → device code (type a code on your phone)
The grant is the identity model. "API calls Graph" → ask: as the user (OBO) or as the app (client-creds)? Different permissions, different audit trail.
2. The token claims to memorize
| Claim | Meaning | Trap |
|---|---|---|
iss | issuer (the tenant) | must equal your expected issuer exactly |
aud | who it's for (string OR list) | skip the check → confused deputy |
exp / nbf | valid window (epoch s) | check with ±300 s skew, strict at the edge |
scp | delegated scopes, space-delimited | a user is present |
roles | app roles, array | no user — app acting as itself |
appid/azp | the client app | who got the token |
oid / tid | object id / tenant id | the principal and its tenant |
kid (header) | which JWKS key signed it | pick the public key by this |
alg (header) | the signature algorithm | pin it — reject alg:none |
Access token: aud = the API, carries scp/roles. ID token: aud = the client,
proves who the user is. Refresh token: opaque, goes back to Entra only.
3. The validation order (fail closed on any miss)
1. parse header → pick key by `kid` from JWKS; alg == what you expect (no `alg:none`)
2. verify signature (constant-time compare — hmac.compare_digest)
─────────────────────── everything above is untrusted attacker JSON ───────────────────────
3. iss == expected issuer (the tenant)
4. aud contains your API ← the confused-deputy guard; never skip
5. nbf ≤ now ≤ exp (apply ±clock-skew, default 300 s)
6. authorize: scp (delegated) or roles (app) contains the required value
401 = authentication failed (1–5: bad/absent/expired/wrong-audience). 403 = authorization
failed (6: valid token, missing scope/role). Don't swap the codes.
4. PKCE in one picture
verifier ──SHA256──► challenge client sends only `challenge` to /authorize
(secret to client) client sends raw `verifier` to /token
challenge = BASE64URL(SHA256(verifier)) # method S256 (the only one to use)
Attacker steals the code but never the verifier, and can't invert the hash → can't redeem
it. PKCE protects the code; you still validate the token. plain (no hash) is a downgrade
— reject it.
5. The numbers & names to tattoo on your arm
| Thing | Value |
|---|---|
| Clock-skew leeway | ±300 s (5 min) — Entra's own allowance |
| PKCE challenge | BASE64URL(SHA256(verifier)), method S256 |
| PKCE verifier length | 43–128 chars, high entropy |
| Entra signing alg | RS256 (asymmetric; verify w/ JWKS public key by kid) |
| base64url | +→-, /→_, no = padding |
| Discovery doc | …/{tenant}/v2.0/.well-known/openid-configuration |
| JWKS endpoint | …/{tenant}/discovery/v2.0/keys |
| v2.0 issuer | https://login.microsoftonline.com/{tid}/v2.0 |
| Access-token lifetime | ~60–90 min (then use the refresh token) |
scp vs roles | delegated (user) vs app (daemon) |
| Implicit flow | dead — PKCE replaced it |
6. az / curl one-liners you'll actually type
# Read the discovery document (endpoints + issuer + jwks_uri) for a tenant
curl -s https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration | jq .
# Fetch the tenant's public signing keys (JWKS)
curl -s https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys | jq '.keys[].kid'
# Get an access token as yourself (then paste it on https://jwt.ms to decode)
az account get-access-token --resource api://<your-api-app-id-uri> --query accessToken -o tsv
# Client-credentials (daemon) token by hand — note grant_type and .default scope
curl -s -X POST https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token \
-d grant_type=client_credentials \
-d client_id=<app-id> -d client_secret=<secret> \
-d scope=api://<your-api>/.default | jq '.access_token'
# Register an app + service principal; add an app role / API permission
az ad app create --display-name my-api
az ad sp create --id <appId> # the service principal (grant RBAC to THIS)
az ad app permission add --id <clientAppId> --api <resourceAppId> --api-permissions <scopeId>=Scope
# Inspect a token's claims locally (jq the middle segment)
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
7. War story shapes you'll relive
- "Valid token, wrong service." → a token minted for service B accepted by service A; A
never checked
aud. The confused deputy. Add theaudcheck; it's the whole defense. - "Login works, the API 401s." → the front-end is sending the ID token (
aud= the client) where the API wants the access token (aud= the API). The validator is right. - "Every token rejected for 10 minutes." → zero clock-skew tolerance + an NTP hiccup, or a
rotated JWKS key the validator cached forever. Add ±300 s leeway; cache JWKS by
kidand refresh on an unknownkid. - "Someone forged an admin token." →
alg:noneaccepted (no algorithm pin) or a non-constant-time signature compare leaked the bytes. Pin the alg; usehmac.compare_digest. - "It works in our tenant, 403s in the customer's." → no service principal / no admin consent in the customer tenant. The app registration isn't the identity; the SP is.
- "The daemon can't act as the user." → it used client-credentials (app
roles), so every downstream call is "the app," not the user. It needed on-behalf-of.
8. Vocabulary that signals you've held the pager
- Confused deputy — a valid token for B accepted by A because A skipped
aud. alg:noneattack — stripping the signature by claiming "no algorithm"; pin the alg.- PKCE —
challenge = BASE64URL(SHA256(verifier)); secret-less proof for public clients. scpvsroles— delegated (user present) vs app (daemon, no user).- App registration vs service principal — blueprint vs instance-in-a-tenant; grant to the SP.
- JWKS /
kid— the issuer's public keys; the header'skidselects which one. - On-behalf-of (OBO) — a middle tier calling downstream as the user.
- Discovery —
/.well-known/openid-configuration; how SDKs self-configure. - Bearer token — "whoever holds it can use it" → short-lived, audience-scoped, over TLS.
- Authn vs authz — who you are (
401) vs what you may do (403).
9. Beginner mistakes that mark you in interviews
- Saying "OAuth" when you mean OIDC (or thinking OIDC replaces OAuth — it's built on it).
- Picking client-credentials where a user is present (you lost the user's identity).
- Using the implicit flow (it's dead) instead of auth-code + PKCE for a SPA.
- Decoding/trusting the access token in the front-end (it's opaque to the client).
- Validating the signature but not
aud(the confused deputy) — or not pinningalg. - Comparing the signature with
==(timing side channel) instead ofhmac.compare_digest. - Zero clock-skew tolerance, or caching one JWKS key forever (self-inflicted outage).
- Confusing app registration with service principal — and not knowing which gets the RBAC.
- Calling a signed JWT "encrypted" and putting a secret in the payload (it's readable).
10. How this phase pays off later
- The validation order is exactly what the API gateway runs on every request (P09).
- Identity is the perimeter is the first gate of the ARM request pipeline (P01) and the input to RBAC evaluation (P04, which runs after this authenticates the caller).
- PKCE's "prove possession, don't store a secret" is the same idea behind OIDC workload-identity federation (P08, secretless CI) and Managed Identity (P12, the token from IMDS with no credential).
scp/roles+audis how every secured Azure resource decides who may touch it.
Now read the WARMUP slowly, then build the engine. After this, no token is a black box — and "is this token safe to trust?" becomes a checklist you can recite under pressure.