🛸 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, then iss, then aud, 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

ClaimMeaningTrap
ississuer (the tenant)must equal your expected issuer exactly
audwho it's for (string OR list)skip the check → confused deputy
exp / nbfvalid window (epoch s)check with ±300 s skew, strict at the edge
scpdelegated scopes, space-delimiteda user is present
rolesapp roles, arrayno user — app acting as itself
appid/azpthe client appwho got the token
oid / tidobject id / tenant idthe principal and its tenant
kid (header)which JWKS key signed itpick the public key by this
alg (header)the signature algorithmpin 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

ThingValue
Clock-skew leeway±300 s (5 min) — Entra's own allowance
PKCE challengeBASE64URL(SHA256(verifier)), method S256
PKCE verifier length43–128 chars, high entropy
Entra signing algRS256 (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 issuerhttps://login.microsoftonline.com/{tid}/v2.0
Access-token lifetime~60–90 min (then use the refresh token)
scp vs rolesdelegated (user) vs app (daemon)
Implicit flowdead — 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 the aud check; 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 kid and refresh on an unknown kid.
  • "Someone forged an admin token."alg:none accepted (no algorithm pin) or a non-constant-time signature compare leaked the bytes. Pin the alg; use hmac.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:none attack — stripping the signature by claiming "no algorithm"; pin the alg.
  • PKCEchallenge = BASE64URL(SHA256(verifier)); secret-less proof for public clients.
  • scp vs roles — 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's kid selects 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

  1. Saying "OAuth" when you mean OIDC (or thinking OIDC replaces OAuth — it's built on it).
  2. Picking client-credentials where a user is present (you lost the user's identity).
  3. Using the implicit flow (it's dead) instead of auth-code + PKCE for a SPA.
  4. Decoding/trusting the access token in the front-end (it's opaque to the client).
  5. Validating the signature but not aud (the confused deputy) — or not pinning alg.
  6. Comparing the signature with == (timing side channel) instead of hmac.compare_digest.
  7. Zero clock-skew tolerance, or caching one JWKS key forever (self-inflicted outage).
  8. Confusing app registration with service principal — and not knowing which gets the RBAC.
  9. 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 + aud is 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.