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

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 analogyEntra
The blueprinta class definitionthe app registration (one, in the home tenant)
The running thingan instancea service principal (one per tenant the app is used in)
What you grant rights tothe instancethe 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-time code comes back.
  • …/{tenant}/oauth2/v2.0/token — the back-channel: a server-to-server POST that 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:

GrantWho's callingCredentialReturnsUse it for
Authorization code + PKCEa user, interactivelythe user's login + a PKCE proofaccess + ID + refreshSPAs, native/mobile, any user-present web app
Client credentialsan app, as itself (no user)the app's secret or certaccess (roles), no ID tokendaemons, cron jobs, service-to-service
On-behalf-of (OBO)a middle-tier API, as the userthe incoming user access tokena downstream access tokenAPI calls a second API keeping the user's identity
Device codea user on a screen with no browsera user_code typed on another deviceaccess + ID + refreshTVs, 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.

TokenAnswersaud (audience)CarriesWho reads it
Access token"may this caller do X to the resource?"the API (api://contoso-api)scp or roles, oid, expthe resource API
ID token"who is this user, to the client?"the client (its client_id)sub, name, preferred_usernamethe client app
Refresh token"give me a new access token"(opaque to the client)nothing you parsesent 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 aud is the client, not the API, so a correct validator (Chapter 9) rejects it on the aud check. (This is a real attack: present the ID token to the API and hope it doesn't check aud.)

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:

  1. The client invents a high-entropy random code_verifier (43–128 chars).
  2. It computes code_challenge = BASE64URL(SHA256(code_verifier)) and sends only the challenge to /authorize (method S256).
  3. At /token, it presents the raw code_verifier.
  4. 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.
  • signatureSIGN(key, base64url(header) + "." + base64url(payload)).

Crucial mechanical detail. The signature is computed over the encoded segments — the exact ASCII bytes header_b64 + "." + payload_b64not 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:

ClaimMeaning
ississuer — the tenant authority that minted the token
audaudience — who the token is for (string or list)
subsubject — the principal (a stable user id within the app)
expexpiry — epoch seconds after which it's invalid
nbfnot-before — epoch seconds before which it's invalid
iatissued-at
scpdelegated scopes, space-delimited (a user delegated these)
rolesapp roles (the app was granted these; no user)
appid/azpthe client application that obtained the token
oid / tidthe 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 iss or exp from 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 whatever alg the header claims, an attacker sets alg: "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 but HS256; a real one pins RS256.
  • 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. Use hmac.compare_digest, which compares in time independent of where the mismatch is.
  • aud is not optional. See Chapter 10 — skipping it is the confused-deputy vulnerability.
  • nbf/exp with 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 (a 401), 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
blockdeny the sign-in outright
grant + require MFAissue only after a second factor
require compliant deviceissue only from a managed/compliant device
require app-protection policyissue 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:

  1. 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.
  2. pkce_challenge(verifier, method) (Ch. 7) — S256 = b64url(sha256(verifier)), plain returns the verifier, unknown method raises. The test recomputes the expected challenge with hashlib.sha256 (a known-answer vector — it equals the RFC 7636 Appendix B value E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM for the standard's verifier).
  3. 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.
  4. 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, wrong iss, wrong aud, expired, not-yet-valid, missing scope, missing role, and the leeway boundaries.
  5. AuthorizationServer.authorize (Ch. 4, 7) — validate client/redirect/scope, store a PKCE-bound one-time code, return it.
  6. 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.
  7. token_client_credentials (Ch. 4, 10) — constant-time secret check, then a token with roles and no scp, 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:

  1. Explain app registration vs service principal (blueprint vs instance-in-a-tenant) and say which one you grant RBAC to.
  2. Draw the authorization-code + PKCE flow and state precisely what PKCE defends against and why the hash makes it work.
  3. Pick the correct grant for a SPA, a daemon, a middle tier, and a CLI — and say why each alternative is wrong.
  4. Tell an access token from an ID token by its aud, and a delegated call from an app-only call by scp-vs-roles.
  5. Recite the JWT validation order and justify each position — why signature precedes every claim, why aud is mandatory, why alg is pinned, why the compare is constant-time, why there's clock-skew leeway.
  6. Define the confused deputy and name the single check that prevents it.
  7. Frame Conditional Access as signals → decision and 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