Warmup Guide — Microsoft Entra ID: OAuth2 / OIDC / JWT
Zero-to-principal primer for Phase 03: the Entra identity model (tenant, app registration vs service principal, the v2.0 endpoints, discovery, JWKS), the OAuth2 grants and exactly when each is correct, OIDC and the authentication-vs-authorization line, PKCE from first principles, the JWT wire format and the validation order that is the security, and the Conditional Access framing. Every concept goes from what it is to why it exists to the mechanism under the hood (diagrams, tables, code) to production significance to the misconceptions that get people owned.
Table of Contents
- Chapter 1: Why Identity Is the Perimeter
- Chapter 2: The Entra ID Model — Tenant, App Registration, Service Principal
- Chapter 3: The Endpoints, Discovery, and JWKS
- Chapter 4: OAuth2 — The Grants and When Each Is Correct
- Chapter 5: Access Token vs ID Token vs Refresh Token
- Chapter 6: OIDC — Authentication vs Authorization
- Chapter 7: PKCE — The Proof Key, From First Principles
- Chapter 8: The JWT Wire Format
- Chapter 9: The Validation Order Is the Security
- Chapter 10:
scpvsroles, and the Confused Deputy - Chapter 11: Conditional Access — Signals to Decision
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Identity Is the Perimeter
From zero. For decades, security meant a network perimeter: a firewall, a DMZ, a VPN — "inside is trusted, outside is not." That model dies in the cloud. Your services run on shared infrastructure, talk to managed APIs over the public internet, and scale across regions; there is no wall to stand behind. So the industry moved the boundary from where you are to who you are: identity is the new perimeter, and every single request must carry a verifiable proof of identity that the receiver checks before doing anything.
What it is. In Azure that proof is a JSON Web Token (JWT) minted by Microsoft Entra ID (formerly Azure AD). Every ARM write (Phase 01's pipeline starts by authenticating this token), every Microsoft Graph call, and every API you protect receives a bearer token and must decide: is this real, is it for me, is it current, and does it permit this action?
Why it exists / production significance. Because the alternatives are worse. Shared API keys leak and never expire; IP allow-lists break the moment anything moves; passwords on the wire are a disaster. A short-lived, cryptographically-signed, audience-scoped token solves all three: it expires (so a leak is time-boxed), it's signed (so it can't be forged), and it names its intended audience (so it can't be replayed elsewhere — if you check). The entire rest of this phase is the machinery that issues and validates that token.
Misconception. "We're behind a VNet / private endpoint, so identity is less important." No — defense in depth means the token is still the boundary inside the network too; lateral movement after one foothold is exactly what a stolen-but-unchecked token enables. The network is a layer; identity is the perimeter.
Chapter 2: The Entra ID Model — Tenant, App Registration, Service Principal
From zero. Before any tokens, you need the nouns. Three of them trip up nearly everyone.
- A tenant is an isolated instance of the Entra directory — one organization's identity
universe, identified by a tenant ID (a GUID). Your users, groups, and apps live in a
tenant. The token issuer (
iss) is scoped to a tenant; a token from tenant A is not a token from tenant B, full stop. - Users and groups are the human identities and their collections (groups are how you assign access at scale instead of per-user).
- An app registration is the global definition of an application: its client id
(
appId, a GUID), its redirect URIs, its secrets/certificates (for confidential clients), the scopes it exposes (if it's an API) and app roles it defines, and its Application ID URI (e.g.api://contoso-api). It lives in the tenant where the app was registered (its home tenant). - A service principal is the app's local identity in a tenant — the concrete object that role assignments, consent, and Conditional Access attach to. When an app is used in a tenant, a service principal for it exists there.
Under the hood — the relationship. The cleanest analogy is class vs instance:
| Object-oriented analogy | Entra | |
|---|---|---|
| The blueprint | a class definition | the app registration (one, in the home tenant) |
| The running thing | an instance | a service principal (one per tenant the app is used in) |
| What you grant rights to | the instance | the service principal |
So a multi-tenant app has one app registration (its global definition) and N service principals (one in each tenant that consented to it). You assign an RBAC role (Phase 04) to the service principal, not the registration.
App registration "contoso-api" (home tenant: contoso)
client_id = 11111111-...
exposes scope Files.Read
defines app role Invoices.Process.All
│ (used in two tenants)
├── service principal in tenant CONTOSO ← RBAC + consent attach here
└── service principal in tenant FABRIKAM ← and here, independently
Production significance. "It worked in my tenant but 403s in the customer's" is almost always a missing service principal or consent in the customer tenant, not a code bug. "Where do I grant the daemon access to the storage account?" — to its service principal, by object id.
Misconception. "App registration and service principal are the same thing / are interchangeable." They are not. The registration is the definition; the service principal is the identity-in-a-tenant. The portal blurs them, which is why so few engineers can explain the difference — and why it's a great interview filter.
Chapter 3: The Endpoints, Discovery, and JWKS
From zero. Every tenant exposes a fixed set of OAuth2/OIDC endpoints under
https://login.microsoftonline.com/{tenant}/. The two you'll touch constantly are the v2.0
endpoints:
…/{tenant}/oauth2/v2.0/authorize— the front-channel: a browser redirect where the user authenticates and (in the auth-code flow) a one-timecodecomes back.…/{tenant}/oauth2/v2.0/token— the back-channel: a server-to-serverPOSTthat exchanges a grant (a code, a secret, an assertion) for tokens.
What discovery is. Rather than hard-code those URLs and the signing keys, a client reads the OpenID Connect discovery document:
GET https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration
→ {
"issuer": "https://login.microsoftonline.com/{tid}/v2.0",
"authorization_endpoint": ".../oauth2/v2.0/authorize",
"token_endpoint": ".../oauth2/v2.0/token",
"jwks_uri": "https://login.microsoftonline.com/{tid}/discovery/v2.0/keys",
"id_token_signing_alg_values_supported": ["RS256"],
...
}
Every Entra SDK fetches this on startup so it never hard-codes an endpoint or a key. The
issuer here is the exact string you'll later require a token's iss to equal.
What JWKS is. The jwks_uri returns a JSON Web Key Set — the issuer's public
signing keys:
{ "keys": [
{ "kid": "abc123", "kty": "RSA", "use": "sig", "n": "<base64url-modulus>", "e": "AQAB" },
{ "kid": "def456", "kty": "RSA", "use": "sig", "n": "...", "e": "AQAB" }
]}
Under the hood — how kid ties it together. A signed token's header names a kid (key
id). To verify, you (1) read the unverified header, (2) find the JWKS key whose kid matches,
(3) verify the signature with that public key. Entra rotates these keys, which is why you
fetch JWKS by kid and cache with refresh — never pin one key forever.
In this phase's lab we sign with HS256 (a shared symmetric secret) instead of Entra's RS256 (asymmetric) purely to stay offline and deterministic — no keypair, no network. The
kid→key→signature shape is identical; only the signature primitive differs. The first extension swaps HS256 for RS256+JWKS and you've modeled real Entra exactly.
Misconception. "I'll just download the public key once." Keys rotate; a validator that
caches one key forever starts rejecting every token the day Entra rolls the key. Cache by
kid, refresh on an unknown kid.
Chapter 4: OAuth2 — The Grants and When Each Is Correct
From zero. OAuth2 (RFC 6749) is an authorization framework: it defines how a client obtains an access token to call a resource on behalf of (or as) some principal, without that resource ever seeing a password. The variations are called grants (or flows), and picking the right one is the first design decision — and a favorite interview probe, because the wrong grant is a security smell.
The four that matter in Azure:
| Grant | Who's calling | Credential | Returns | Use it for |
|---|---|---|---|---|
| Authorization code + PKCE | a user, interactively | the user's login + a PKCE proof | access + ID + refresh | SPAs, native/mobile, any user-present web app |
| Client credentials | an app, as itself (no user) | the app's secret or cert | access (roles), no ID token | daemons, cron jobs, service-to-service |
| On-behalf-of (OBO) | a middle-tier API, as the user | the incoming user access token | a downstream access token | API calls a second API keeping the user's identity |
| Device code | a user on a screen with no browser | a user_code typed on another device | access + ID + refresh | TVs, CLIs, IoT |
Under the hood — the canonical one (authorization code + PKCE):
user client (SPA/native) Entra /authorize Entra /token resource API
│ │ │ │ │
│ click "Sign in" │ │ │ │
│────────────────►│ redirect with │ │ │
│ │ client_id, redirect_uri, │ │ │
│ │ scope, code_challenge ──────►│ (user authenticates,│ │
│ │ │ Conditional Access) │ │
│ │◄──── redirect back with ─────│ │ │
│ │ one-time `code` │ │ │
│ │ POST code + code_verifier ─────────────────────────►│ verify verifier │
│ │ │ │ hashes to challenge│
│ │◄──── access_token + id_token (+ refresh_token) ──────│ │
│ │ call API with Bearer access_token ──────────────────────────────────────►│
│ │ │ │ validate JWT ────►│
Why each exists / when it's wrong.
- Authorization code + PKCE is the only correct flow for a user in a browser or native
app. The older implicit flow (token straight from
/authorize) is dead — it leaked tokens in URLs and browser history. PKCE made auth-code safe even for clients that can't keep a secret (Chapter 7), so it replaced implicit entirely. - Client credentials is for no user. Using it where a user is present means you've lost the user's identity (everything becomes "the app did it"), which breaks auditing and least-privilege. Using auth-code for a daemon means there's no human to log in — it can't work.
- On-behalf-of is the only way to call a downstream API as the user from a middle tier. The wrong move — having the middle tier use client-credentials — silently elevates every downstream call to the app's permissions instead of the user's.
- Device code exists because some clients have no browser to redirect; using it elsewhere is just a worse UX.
Production significance. The grant is the identity model. When someone says "the API calls Graph," your first question is "as the user (OBO) or as the app (client-creds)?" — the answer changes the permissions, the audit trail, and the blast radius.
Chapter 5: Access Token vs ID Token vs Refresh Token
From zero. A flow can hand back up to three tokens, and conflating them is a top-three OAuth2 mistake.
| Token | Answers | aud (audience) | Carries | Who reads it |
|---|---|---|---|---|
| Access token | "may this caller do X to the resource?" | the API (api://contoso-api) | scp or roles, oid, exp… | the resource API |
| ID token | "who is this user, to the client?" | the client (its client_id) | sub, name, preferred_username… | the client app |
| Refresh token | "give me a new access token" | (opaque to the client) | nothing you parse | sent back to Entra only |
Under the hood — the aud is the tell. The same login produces an access token whose
aud is the API and an ID token whose aud is the client. That's not a detail — it's the
whole point:
- The client must never use the access token's contents to make decisions; to the client it's an opaque string to forward. Decoding and trusting an access token in the front-end is a classic bug.
- The API must never accept an ID token in place of an access token — the ID token's
audis the client, not the API, so a correct validator (Chapter 9) rejects it on theaudcheck. (This is a real attack: present the ID token to the API and hope it doesn't checkaud.)
Production significance. "Login works but the API rejects my token" is often the front-end
sending the ID token (its aud is the client) where the API wants the access token (aud
= the API). The aud mismatch is the validator doing its job.
Misconception. "A token is a token." No — three tokens, three audiences, three readers.
Read the aud and you know instantly which one you're holding and whether it's for you.
Chapter 6: OIDC — Authentication vs Authorization
From zero. OAuth2 alone is about authorization — "what may this caller do." It was
not designed to tell a client who the user is; people bolted that on insecurely for years.
OpenID Connect (OIDC) is the standard layer that fixes it: it adds the ID token (a JWT
proving the user's identity to the client), the discovery document (Chapter 3), and a
userinfo endpoint.
The one sentence to carry forever:
Authentication answers who you are. Authorization answers what you may do. OIDC does the first; OAuth2 does the second; they ride together but are different questions.
Under the hood — where the line falls in your validator. This phase's validate_jwt
literally splits along it:
AUTHENTICATION of the token (is it real, current, for me, from whom I trust):
structure / alg → signature → iss → aud → nbf/exp
AUTHORIZATION (may the caller do this specific thing):
required_scopes ⊆ scp and/or required_roles ⊆ roles
A 401 Unauthorized is an authentication failure (bad/absent/expired/wrong-audience
token). A 403 Forbidden is an authorization failure (a valid token that lacks the scope or
role). Returning the wrong code leaks information and confuses clients; the distinction maps
exactly onto the two halves above.
Production significance. Interviewers love "what's the difference between authn and authz?" — and they love it more when you say "and here's where the line falls in a JWT validator," because that proves you've implemented the distinction, not just defined it.
Misconception. "OIDC replaces OAuth2." No — OIDC is built on OAuth2; you use both at once. The ID token (OIDC) authenticates the user to the client; the access token (OAuth2) authorizes the call to the API.
Chapter 7: PKCE — The Proof Key, From First Principles
From zero. A confidential client (a server) can keep a secret, so at the token
endpoint it can prove "I'm the app that started this login" by presenting that secret. A
public client — a SPA whose code ships to the browser, a mobile app on a user's phone —
cannot keep a secret; anything you embed is extractable. So how does a public client prove,
at /token, that it (and not an attacker who intercepted the code) started the flow?
What PKCE is. Proof Key for Code Exchange (RFC 7636) solves it with a one-time, per- login proof and no stored secret:
- The client invents a high-entropy random
code_verifier(43–128 chars). - It computes
code_challenge = BASE64URL(SHA256(code_verifier))and sends only the challenge to/authorize(methodS256). - At
/token, it presents the rawcode_verifier. - The server re-computes
BASE64URL(SHA256(verifier))and checks it equals the stored challenge.
Under the hood — why it defeats interception. SHA-256 is a one-way function. Write the challenge as
$$ \text{challenge} = \mathrm{BASE64URL}\big(\mathrm{SHA256}(\text{verifier})\big) $$
An attacker who intercepts the redirect sees the challenge but not the verifier, and
cannot invert the hash to recover it. An attacker who intercepts the code (the prize in
an auth-code interception attack) still can't redeem it at /token, because they can't supply
the matching verifier. The flow is bound to whoever generated the verifier.
attacker sees: challenge ✗ (one-way)→ verifier # can't go backward
attacker steals: the code ✗ (no verifier)→ tokens # code alone is useless
only the real client knows: verifier → redeems code # because it made it
S256 is the method you want. plain (where challenge == verifier, no hash) exists only
for clients that genuinely can't SHA-256, and it's a downgrade — an interceptor of the
challenge would have the verifier — so a production server should reject plain.
Production significance. PKCE is now mandatory for public clients and recommended for all clients (OAuth 2.1 folds it in). It is the reason the implicit flow is dead. The deeper lesson — prove possession instead of storing a password — is the same idea behind Managed Identity (P12) and OIDC federation (P08): the modern secretless default.
Misconception. "PKCE replaces the client secret." For public clients there was no usable secret to replace — PKCE is what makes secret-less safe. For confidential clients you use both (PKCE and the secret). And PKCE protects the code, not the token; you still validate the token (Chapter 9).
Chapter 8: The JWT Wire Format
From zero. A JWT is three base64url segments joined by dots:
base64url(header) . base64url(payload) . base64url(signature)
eyJhbGciOiJI... . eyJpc3MiOiJod... . dBjftJeZ4CV...
- header — JSON like
{"alg":"HS256","typ":"JWT","kid":"abc123"}: the signature algorithm and the key id. - payload — the claims JSON (the data):
iss,aud,exp,scp, etc. - signature —
SIGN(key, base64url(header) + "." + base64url(payload)).
Crucial mechanical detail. The signature is computed over the encoded segments — the
exact ASCII bytes header_b64 + "." + payload_b64 — not over the raw JSON. To verify you
re-sign those same bytes and compare. A common bug is to JSON-parse, re-serialize, and
re-sign — but JSON re-serialization can change byte order or spacing and the signature won't
match. Verify the bytes that were signed.
base64url, not base64. JOSE uses URL-safe base64 (+→-, /→_) with the = padding
stripped. Decoders must re-add padding to a multiple of four before decoding. Using
standard base64 or leaving padding on is the "works locally, 401s in Entra" classic.
The claims you must know cold:
| Claim | Meaning |
|---|---|
iss | issuer — the tenant authority that minted the token |
aud | audience — who the token is for (string or list) |
sub | subject — the principal (a stable user id within the app) |
exp | expiry — epoch seconds after which it's invalid |
nbf | not-before — epoch seconds before which it's invalid |
iat | issued-at |
scp | delegated scopes, space-delimited (a user delegated these) |
roles | app roles (the app was granted these; no user) |
appid/azp | the client application that obtained the token |
oid / tid | the principal's object id / the tenant id |
Misconception. "A JWT is encrypted, so it's safe to put secrets in it." A signed JWT
(JWS) is signed, not encrypted — anyone can base64url-decode and read the payload
(decode_jwt_unverified does exactly that). Never put a secret in a JWT payload. The
signature gives integrity, not confidentiality.
Chapter 9: The Validation Order Is the Security
This is the chapter the whole lab exists for. A resource API that receives a bearer token must answer, in order, failing closed (any miss → reject):
1. structure / alg parse 3 segments; header.alg == the algorithm I expect
(reject `alg: none` and algorithm-substitution)
2. signature recompute and constant-time-compare (hmac.compare_digest)
─────────────── ↑ EVERYTHING ABOVE THIS LINE IS UNTRUSTED ATTACKER JSON ↑
3. iss == my expected issuer (the tenant I trust)
4. aud my API ∈ the token's audience(s) ← skip → CONFUSED DEPUTY
5. nbf nbf ≤ now + leeway (token already valid, modulo skew)
6. exp exp > now − leeway (token not expired, modulo skew)
7. authorization required_scopes ⊆ scp and/or required_roles ⊆ roles
Why this order — under the hood.
- Signature first, before any claim. Until the signature verifies, the payload is just
bytes an attacker controls. Reading
issorexpfrom an unverified token and acting on it is trusting attacker input. So the cryptographic gate comes before every claim check. Everything below the line in the diagram is only safe to read because the signature passed. - Pin the
alg. If you trust whateveralgthe header claims, an attacker setsalg: "none"and ships an unsigned token — and a naive verifier "verifies" an empty signature and passes it. (There's a sibling attack: an RS256 verifier tricked into treating the RSA public key as an HS256 secret.) Defense: accept only the algorithm you expect. The lab's validator rejects anything butHS256; a real one pinsRS256. - Constant-time signature compare. Comparing the signature with a normal
==that short-circuits on the first differing byte leaks, via timing, how many leading bytes matched — enough to forge a signature byte-by-byte over many requests. Usehmac.compare_digest, which compares in time independent of where the mismatch is. audis not optional. See Chapter 10 — skipping it is the confused-deputy vulnerability.nbf/expwith leeway. Clocks drift. Entra allows ±5 minutes (300 s) of skew; a validator with zero tolerance rejects legitimate tokens whenever an NTP server hiccups. The boundary is the off-by-one interviewers probe: with leeway $L$ and current time $t$, a token is expired iff $exp \le t - L$ and not-yet-valid iff $nbf > t + L$.- Authorization last. A token can be perfectly authentic and still not permit this
action. Scope/role checks are authorization (a
403), not authentication (a401), so they come after the token is proven real.
Mechanism, in code (the heart of the lab):
# 2. signature — constant-time, after pinning alg to HS256
signing_input = f"{seg0}.{seg1}".encode("ascii")
expected = hmac.new(key, signing_input, hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64url_decode(seg2)):
raise JWTValidationError("signature: HMAC verification failed")
# ── below here the payload is trusted ──
# 4. aud — string OR list on both sides; membership, not equality
if token_auds.isdisjoint(wanted_auds):
raise JWTValidationError("aud: ...") # confused-deputy guard
# 6. exp — strict, with leeway
if exp is not None and exp <= now - leeway:
raise JWTValidationError("exp: token expired")
Production significance. Most real-world JWT breaches are a missing or reordered check:
no aud (confused deputy), no alg pin (alg:none), a non-constant-time compare (timing),
or zero skew (self-inflicted outage). The order is not style — it's the security.
Chapter 10: scp vs roles, and the Confused Deputy
From zero — the two authorization claims. After a token is proven authentic, how it authorizes depends on which grant minted it:
scp(scope) — a space-delimited string like"Files.Read User.Read". It means delegated permission: a user is present and delegated these scopes to the client. Produced by the authorization-code flow.roles— a JSON array like["Invoices.Process.All"]. It means application permission: no user — the app itself was granted these roles. Produced by the client-credentials flow.
So scp vs roles is how a validator tells "a user delegated this" from "an app is
acting as itself" — which changes what you log, what you audit, and how much you trust the
call. The lab's two grants produce exactly these two shapes (and the tests assert the
client-credentials token has roles and no scp).
The confused deputy — what it is. A deputy is a program with more authority than its caller (your API can read the database; the caller can't directly). A confused deputy is tricked into misusing its authority on behalf of someone who shouldn't have it. With JWTs the attack is concrete:
Entra issues a valid token aud = api://SERVICE-B to a caller legitimately using B.
The caller replays that exact token to your SERVICE-A.
If A verifies the signature, iss, and exp — but NOT aud — A accepts it.
→ A token minted for B now acts on A. A is the confused deputy.
The token is genuinely signed by Entra and not expired — every check passes except the
one that asks "was this token minted for ME?" That one check — aud contains my API — is
the entire defense. It is why Chapter 9 puts aud in the required path and the cheat sheet
says never trust a token you didn't validate aud on.
Production significance. Microservice meshes are full of services that share an issuer and
a signing key; the only thing keeping service A from honoring service B's tokens is the
aud check. Skip it once and any service can impersonate any other.
Misconception. "If the signature is valid the token is safe." Validity proves it came from Entra unmodified — it says nothing about who it was for. Authenticity without audience is the confused deputy.
Chapter 11: Conditional Access — Signals to Decision
From zero. Everything so far validates a token once it exists. Conditional Access (CA)
is the policy engine that decides whether to issue one — the "Zero Trust" control plane that
runs at /authorize before the token is minted.
Under the hood — signals to a decision. A CA policy is, in effect, IF signals THEN control:
| Signal (the "if") | Example |
|---|---|
| user / group | "members of Finance" |
| application | "accessing the Finance API" |
| location / IP | "from outside the corporate network" |
| device state | "non-compliant or unmanaged device" |
| sign-in risk | "Entra ID Protection flags this sign-in as risky" |
| Control (the "then") | Effect |
|---|---|
| block | deny the sign-in outright |
| grant + require MFA | issue only after a second factor |
| require compliant device | issue only from a managed/compliant device |
| require app-protection policy | issue only to a managed app |
sign-in attempt
│ collect signals: who, which app, where, what device, how risky
▼
evaluate CA policies (all matching policies combine; block wins)
│
├── all satisfied ────────────► issue token (the flows in Ch. 4)
├── needs MFA ───────────────► challenge, then issue
└── blocked ─────────────────► no token
Production significance. CA is how "valid password" becomes "valid password, from a compliant laptop, in an expected country, with MFA, for a non-risky sign-in." It's the layer that contains stolen credentials: the password alone no longer gets a token. You don't build CA in this lab, but a principal frames token issuance as conditional, not automatic.
Misconception. "MFA and Conditional Access are the same." MFA is one possible control; CA is the engine that decides when to require MFA (or block, or demand a compliant device) based on signals.
Lab Walkthrough Guidance
Lab 01 — OAuth2 / OIDC / JWT Engine, suggested order:
b64url_encode/b64url_decode(Ch. 8) — URL-safe base64, padding stripped on encode and re-added on decode. Test the round-trip and that no=survives.pkce_challenge(verifier, method)(Ch. 7) —S256=b64url(sha256(verifier)),plainreturns the verifier, unknown method raises. The test recomputes the expected challenge withhashlib.sha256(a known-answer vector — it equals the RFC 7636 Appendix B valueE9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cMfor the standard's verifier).make_jwt/decode_jwt_unverified(Ch. 8) — sign HS256 over the encoded segments (sorted-key JSON so it's deterministic); decode without verifying. Round-trip and determinism tests.validate_jwt(Ch. 9–10) — the seven ordered checks, failing closed and naming the failed check. This is where the negative tests live: bad signature, tampered payload,alg:none, wrongiss, wrongaud, expired, not-yet-valid, missing scope, missing role, and the leeway boundaries.AuthorizationServer.authorize(Ch. 4, 7) — validate client/redirect/scope, store a PKCE-bound one-time code, return it.token_authorization_code(Ch. 4, 5, 7) — pop the code (single-use, even on failure), re-derive the challenge from the verifier and constant-time-compare, then sign the access token (scp, user,aud= API) and the ID token (aud= client). Test wrong-verifier rejection and single-use replay.token_client_credentials(Ch. 4, 10) — constant-time secret check, then a token withrolesand noscp, no ID token. Test the wrong-secret rejection.
Run it red, make it green: pytest test_lab.py -v, then LAB_MODULE=solution pytest test_lab.py -v, then python solution.py for the worked flow.
Success Criteria
You are ready for Phase 04 when you can, from memory:
- Explain app registration vs service principal (blueprint vs instance-in-a-tenant) and say which one you grant RBAC to.
- Draw the authorization-code + PKCE flow and state precisely what PKCE defends against and why the hash makes it work.
- Pick the correct grant for a SPA, a daemon, a middle tier, and a CLI — and say why each alternative is wrong.
- Tell an access token from an ID token by its
aud, and a delegated call from an app-only call byscp-vs-roles. - Recite the JWT validation order and justify each position — why signature precedes every
claim, why
audis mandatory, whyalgis pinned, why the compare is constant-time, why there's clock-skew leeway. - Define the confused deputy and name the single check that prevents it.
- Frame Conditional Access as
signals → decisionand place it at token issuance.
Interview Q&A
Q: Walk me through the authorization-code + PKCE flow. What does PKCE defend against?
The client redirects the user to /authorize with its client_id, redirect_uri, requested
scope, and a code_challenge = BASE64URL(SHA256(code_verifier)). The user authenticates
(Conditional Access runs here), and Entra redirects back with a one-time code. The client
then POSTs that code plus the raw code_verifier to /token; Entra re-hashes the
verifier, checks it equals the stored challenge, and returns the access + ID (+ refresh)
tokens. PKCE defends against authorization-code interception: an attacker who steals the
code can't redeem it because they never saw the verifier and can't invert the SHA-256 of the
challenge. It's what makes auth-code safe for public clients that can't keep a secret — which
is why the implicit flow is dead.
Q: Exactly what does a resource API check on an incoming JWT, and in what order?
Failing closed, in order: (1) structure and that alg is the algorithm I expect — reject
alg:none; (2) the signature, constant-time, against the issuer's key picked by kid —
and nothing below here is trustworthy until this passes; (3) iss equals the tenant I trust;
(4) aud contains my API — skip this and I'm a confused deputy; (5) nbf <= now + leeway;
(6) exp > now - leeway, with ~300 s skew; (7) authorization — the required scp (delegated)
or roles (app) is present. The order matters: I never read a claim I haven't verified the
signature on, and authorization (a 403) only happens after the token is proven authentic (or
it's a 401).
Q: What's the difference between scp and roles?
scp is a space-delimited string of delegated scopes — a user is present and delegated
those permissions to the app; it comes from the authorization-code flow. roles is an array
of app roles — no user, the application itself was granted them; it comes from the
client-credentials flow. So the validator reads scp vs roles to know whether a human
delegated this action or an app is acting as itself — which changes auditing and trust.
Q: What's the confused-deputy attack and which single check prevents it?
Entra mints a valid, signed, unexpired token whose aud is service B. An attacker replays it
to service A. If A checks the signature, iss, and exp but not aud, A accepts a token
that was never meant for it — A is the confused deputy, acting on B's authorization. The single
defense is the aud check: require that my API is in the token's audience. Authenticity
proves Entra issued it; only aud proves it was issued for me.
Q: Access token vs ID token — what's each for and who's the audience?
The access token authorizes a call to a resource; its aud is the API, it carries
scp/roles, and only the API should read it (it's opaque to the client). The ID token
(OIDC) authenticates the user to the client; its aud is the client, it carries
sub/name/preferred_username, and the client reads it to know who logged in. A correct API
rejects an ID token on the aud check, because the ID token isn't for the API.
Q: Why HS256 in the lab but RS256 in real Entra — does it change the validation?
HS256 is symmetric (one shared secret signs and verifies); we use it to stay offline and
deterministic with no keypair. RS256 is asymmetric — Entra signs with a private key you never
see and you verify with the public key from the tenant's JWKS, selected by the token's
kid. The only thing that changes is the signature primitive and key management (fetch + cache
JWKS by kid, handle rotation). The order, the claims (iss/aud/exp/nbf/scp/roles),
and the confused-deputy and alg:none defenses are identical.
Q: App registration vs service principal? The app registration is the global definition of an app (client id, redirect URIs, secrets, exposed scopes, app roles) — one, in its home tenant. The service principal is the app's local identity in a tenant — the object that RBAC role assignments, admin consent, and Conditional Access attach to; there's one per tenant the app is used in. Blueprint vs instance. You grant access to the service principal, not the registration.
References
- RFC 6749 — The OAuth 2.0 Authorization Framework (the grants: authorization code, client credentials, refresh) — https://www.rfc-editor.org/rfc/rfc6749
- RFC 7636 — Proof Key for Code Exchange (PKCE) (
code_verifier/code_challenge,S256, the Appendix B test vector) — https://www.rfc-editor.org/rfc/rfc7636 - RFC 7519 — JSON Web Token (JWT) (claims:
iss/aud/exp/nbf/sub) — https://www.rfc-editor.org/rfc/rfc7519 - RFC 7515 — JSON Web Signature (JWS) (the base64url-segments wire format and signing input) — https://www.rfc-editor.org/rfc/rfc7515
- OpenID Connect Core 1.0 — the ID token and authentication layer — https://openid.net/specs/openid-connect-core-1_0.html
- Microsoft identity platform — the v2.0 endpoints, the supported flows, access-token and ID-token claims, and validation guidance — https://learn.microsoft.com/entra/identity-platform/ (see Authentication flows and app scenarios, Access tokens, ID tokens, and Validating tokens).
- Conditional Access — What is Conditional Access? — https://learn.microsoft.com/entra/identity/conditional-access/overview
- The track's own CHEATSHEET.md and GLOSSARY.md.