CMD Guide
HomeSystem DesignAuthorization

Authorization — PKCE, Multi-Tenancy, Zanzibar & Revocation (Deep Dive)

Authorization — PKCE, Multi-Tenancy, Zanzibar & Revocation (Deep Dive)

Every gap this page closes has the same shape: a check trusts the wrong input, or trusts a cache that is older than the fact it needs, or assumes a dependency is always up. PKCE fixes a check that used to trust possession of a secret; algorithm pinning fixes a verifier that trusts a header the attacker controls; tenant scoping fixes a check that trusts a client-supplied ID; fail-closed design fixes the assumption that the policy engine is always reachable; Zanzibar's zookie fixes a check that trusts a cache that hasn't caught up yet; refresh-token rotation fixes the assumption that a stolen token stays quiet. This page is the "what a senior engineer actually watches for" layer on top of the OAuth/JWT/RBAC-ABAC-ReBAC mechanics covered elsewhere in this guide.

1. PKCE — binding the code to whoever started the flow

A confidential client (a backend with a secret) authenticates its token exchange with a client_secret. A public client — a single-page app or a mobile app — ships its code to the user's device, so anything baked into that bundle is not a secret; any string in it can be extracted with dev tools or a decompiler. PKCE (RFC 7636) replaces "prove you know the secret" with "prove you are the same party that started this specific flow":

  1. Client generates a random code_verifier (43–128 characters) and computes code_challenge = BASE64URL(SHA256(code_verifier)).
  2. /authorize request carries only the challenge: code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256. The verifier itself never leaves the device yet.
  3. The authorization server stores the challenge alongside the issued authorization code.
  4. On the token exchange, the client finally sends the raw code_verifier. The server recomputes SHA256(code_verifier) and checks it equals the stored challenge before minting tokens.

Trace why interception fails: an attacker who captures the redirect (a malicious app registered on the same custom URI scheme, a leaky referrer header, a compromised network) obtains the authorization code — but not the code_verifier, which never appeared in that URL. Redeeming the stolen code at /token without the matching verifier fails the SHA256 check. The proof is bound to the device that generated the verifier, not to whoever holds the code.

Why it now applies to every client, not just public ones: a confidential client's secret protects the token exchange only if nothing else leaks the code first — PKCE is a second, independent binding that survives even if a secret ever leaks or is misconfigured, so current guidance (OAuth 2.0 Security BCP) is PKCE for all clients, confidential or not. The flow it replaces — the implicit flow, which returned tokens directly in the redirect fragment with no code-exchange step at all — is deprecated precisely because it has no equivalent binding: a token in a URL fragment can leak via browser history, referrer headers, or a malicious redirect, and there is no secret or verifier to check at the end.

2. RS256 verification via JWKS — and the attack that same setup enables

A real Google id_token is signed RS256 (RSA signature over SHA-256): Google's authorization server holds a private key and signs; any verifier fetches Google's public keys from a JWKS endpoint (https://www.googleapis.com/oauth2/v3/certs), matches the token header's kid (key id) to the right public key in that JSON Web Key Set, and checks the signature with it. This is asymmetric on purpose: the public key can be published and cached everywhere without letting anyone forge a token, because forging RS256 requires the private key.

The exact fact that makes this safe — the verification key is public — is what enables the classic RS256→HS256 algorithm-confusion attack. HS256 is symmetric: the same secret both signs and verifies. Many older JWT libraries read the algorithm to use for verification from the token's own alg header and passed a single "key" variable into a generic verify(token, key) call. If a verifier is configured to accept RS256 tokens with "the public key" as that key variable, and it does not also pin the algorithm, an attacker can:

  1. Take the RSA public key (deliberately public — anyone can fetch it from the JWKS endpoint).
  2. Forge a token with header {"alg":"HS256","typ":"JWT"} and any payload they want (e.g. {"sub":"attacker","role":"admin"}).
  3. Compute signature = HMAC-SHA256(header.payload, public_key_bytes) — using the public key's raw text as the HMAC secret. This is legal HMAC math; there is nothing to "break."
  4. Send the forged token. The verifier reads alg: HS256 from the header, runs HMAC-SHA256 with "the key" (the same public key variable it uses for RS256), gets a match, and accepts a token the attacker fully controls.

The bug is not in RSA or HMAC — both are doing exactly what they are asked. The bug is a verifier that lets the attacker's own header choose which algorithm — and therefore which trust model — to apply. Mitigation: pin the expected algorithm outside the token. The verifier call should look like jwt.verify(token, publicKey, { algorithms: ["RS256"] }) — an explicit allow-list the library enforces itself, never derived from the token under test. Modern JOSE libraries require this list; treat any verifier without one as broken.

3. Multi-tenant authorization — scope every check by a token-derived tenant_id

In a multi-tenant system, "is this user allowed to read this invoice" is incomplete without "and is this invoice actually in this user's tenant." The tenant_id used for that second check must be derived from the verified token — a claim the authorization server put there when it authenticated the user — never taken from anything the client supplies on the request itself (a URL path segment, a query parameter, a request body field, or a header the client controls).

The failure, concretely: a service exposes GET /api/tenants/{tenantId}/invoices/{id} and authorizes with something like if (token.isValid()) { return db.query("SELECT * FROM invoices WHERE tenant_id = ? AND id = ?", request.tenantId, request.id) }. The token proves the caller is a real, logged-in user of some tenant — but the query trusts request.tenantId from the URL. A user authenticated for tenant 42 simply requests /api/tenants/17/invoices/9001; the query scopes by tenant 17 (attacker-supplied), not by the tenant the token actually proves, and returns another company's invoice. This is Broken Object-Level Authorization (BOLA/IDOR) at the tenant boundary, and it is exploitable purely by editing an ID — no token forgery required.

The fix is structural, not a better check: read tenant_id only from the validated token's claims, and either ignore the URL's tenantId entirely (scope every query by token.tenant_id) or, if it must appear in the URL for routing/readability, treat a mismatch between token.tenant_id and the URL's value as a hard failure — never let the URL value substitute for or override the token's value in the actual data access.

Claims that commonly drive authorization decisions: sub (who), roles/groups (RBAC), scope (OAuth-granted actions, e.g. invoices:read), tenant_id (multi-tenant scoping), and permissions (fine-grained ABAC/PBAC). The API gateway or service parses these from the verified token and passes them to the PDP as the subject's attributes. A claim the client could modify without server verification is not an authorization input — it is an attack surface.

4. Fail-closed vs availability — the PDP becomes a hard dependency

The secure default when a Policy Decision Point cannot be reached is fail closed: deny, return 403, rather than permit an unverified request. That default is correct — but it has a direct, often under-priced consequence: it makes the PDP's availability a multiplicative factor in the product's availability. If every protected request calls the PDP synchronously and the PDP is down, degraded, or unreachable across a network partition, every one of those requests now fails — the app servers and database can be perfectly healthy and the product is still down, because the authorization layer, whose job is to prevent a narrow class of misuse, has become a single point of failure for the whole system.

Mitigations that keep fail-closed without making the PDP a global outage switch:

5. A distributed PDP under partition — Zanzibar's CAP stance

Google's Zanzibar is exactly the "PDP at global scale" case: a single authorization service backing Drive, Calendar, and other properties, answering permission checks from anywhere in the world. Rather than requiring every check to synchronize with the single freshest global write before answering — which would mean failing or stalling checks during any partition or replication lag — Zanzibar leans toward availability with bounded, explicit staleness: a replica keeps answering checks from the snapshot it currently has, and the caller states, via a token attached to the check, exactly how fresh that snapshot needs to be for this specific decision. That token is the zookie (§7), and it is the mechanism that lets Zanzibar avoid the two bad extremes — always-stale (permit things that were just revoked) and always-synchronous (403 the world every time a link between regions is slow). The stance is not hypothetical caution — the paper (Pang et al., USENIX ATC 2019) reports serving roughly 10 million authorization QPS at <10 ms p95 globally, with >99.999% availability over three years of production use. A latency budget that tight is only reachable by answering from local replicas, which is exactly why the design accepts bounded staleness rather than global synchrony.

6. The new-enemy problem — the canonical bug the zookie prevents

Name the bug precisely: an ACL revocation and a later content write are two independent writes to two different stores (a relationship/ACL store and a content store), each replicating on its own schedule. If a reader's check lands on a replica that has absorbed the content write but not yet the earlier revoke, the reader — someone who was just removed — can see content created after their removal. They have become a "new enemy" who was never supposed to see it, and the system lets them in purely because the two writes raced across replicas in the wrong order relative to each other.

Timeline: Alice revokes Bob from folder F at t=10.000 on leaseholder R1; that revoke replicates to R2 only by t=10.200. Alice creates Doc D under F at t=10.050, which replicates to R2 fast, by t=10.060. Bob's read of D lands on R2 at t=10.100: R2 already has D but has not yet seen the revoke, so Bob wrongly reads a document created after his access was removed. Fix: D's write carries a zookie >= t=10.000, forcing the read to wait for a snapshot that has caught up past the revoke, which then correctly denies.
Timeline: Alice revokes Bob from folder F at t=10.000 on leaseholder R1; that revoke replicates to R2 only by t=10.200. Alice creates Doc D under F at t=10.050, which replicates to R2 fast, by t=10.060. Bob's read of D lands on R2 at t=10.100: R2 already has D but has not yet seen the revoke, so Bob wrongly reads a document created after his access was removed. Fix: D's write carries a zookie >= t=10.000, forcing the read to wait for a snapshot that has caught up past the revoke, which then correctly denies.

Trace it with illustrative timestamps (the numbers are for teaching the mechanism, not measured figures): Alice removes Bob from folder F at t=10.000; that write commits at the authoritative replica R1 immediately but takes until t=10.200 to reach a second region, R2. Fifty milliseconds later, at t=10.050, Alice creates a new document D inside F (inheriting F's membership); this write, on a different, faster-replicating content store, reaches R2 by t=10.060. Bob's read of D is routed to R2 at t=10.100. At that instant, R2 already has D (10.060 < 10.100) but has not yet received the revoke (10.200 > 10.100) — so the check "is Bob a member of F?" still evaluates true on that replica, and Bob wrongly reads a document that was created strictly after his access was pulled.

7. Zookie derivation — a snapshot watermark that lower-bounds freshness

A zookie is not a secret and not a signature over the data — it is an opaque token that encodes a snapshot point in the underlying globally-ordered store (in Zanzibar's implementation, a timestamp from the store's consistency mechanism). Every write returns one; a check can be handed one and required to honor it. Concretely, the fix to the trace above: the write that creates D is itself causally dependent on the current membership of F — Alice could only create it under the assumption that membership already reflects her revoke — so that write is chained to a zookie ≥ t=10.000. When Bob's read of D is required to honor that zookie, R2 is no longer allowed to answer from whatever snapshot it happens to have; it must answer from a snapshot at least as new as the zookie's watermark. Since R2 only reaches that watermark at t=10.200, the read either blocks or is served by a replica that has already caught up — and only then does it correctly see the revoke and return DENY. The zookie turns "how fresh must this one check be" from an unanswerable global question into a specific, checkable lower bound tied to the exact write the check depends on.

8. Practical revocation architecture — short JWT + opaque refresh + rotation

The standard shape that resolves "JWTs can't really be revoked" without giving up JWT's scaling story is a two-tier token pair:

That alone still leaves a gap: a stolen refresh token is valid for as long as its own long lifetime, unless something notices the theft. Refresh-token rotation with reuse detection closes it: every time a refresh token is used, the server issues a brand-new refresh token and immediately invalidates ("marks used") the one just presented, chaining both to the same token family. If an already-invalidated refresh token is ever presented again, there are only two explanations — a client retried after losing a response (rare, recoverable), or someone is replaying a token they stole earlier while the legitimate holder has since rotated past it (a compromise in progress). The safe policy treats any reuse as the latter and revokes the entire family — every token descended from that original login — forcing both the attacker and the legitimate user to authenticate again. This bounds a stolen refresh token's usable window to "before the legitimate client's next refresh," rather than "until its long expiry."

Sequence: client logs in and gets short-lived JWT access token AT1 plus opaque refresh token RT1 in family F1. AT1 is used against the Resource API with zero server lookups. When AT1 expires the client exchanges RT1 for AT2 + RT2, and RT1 is marked used. If an attacker who stole RT1 replays it, the server detects reuse of an already-used token and revokes the entire token family, forcing both the legitimate client and the attacker to log in again.
Sequence: client logs in and gets short-lived JWT access token AT1 plus opaque refresh token RT1 in family F1. AT1 is used against the Resource API with zero server lookups. When AT1 expires the client exchanges RT1 for AT2 + RT2, and RT1 is marked used. If an attacker who stole RT1 replays it, the server detects reuse of an already-used token and revokes the entire token family, forcing both the legitimate client and the attacker to log in again.

9. The boundary case with no per-request boundary: long-lived connections and batch jobs

"Authorize every request" assumes there is a request to hang the check on. A websocket held open for hours, a streaming subscription, or a long-running batch job that processes millions of records under one identity's permissions has no such boundary — the permission that was valid when the connection opened or the job started may be revoked minutes into a session that has no natural next "request" to re-check at. Four complementary answers, chosen by how much revocation lag is tolerable:

Judgment layer — when to use which, and the named alternative

Pitfalls

Takeaways


Sources: RFC 7636 (PKCE) and the OAuth 2.0 Security Best Current Practice (RFC 9700, PKCE for all clients, refresh token rotation and reuse detection); RFC 7519 (JWT) and the OWASP JSON Web Token Cheat Sheet (algorithm-confusion attacks); Google's Zanzibar paper (Pang et al., USENIX ATC 2019) for the new-enemy problem and zookies; OWASP API Security Top 10 (BOLA/IDOR at the tenant boundary); Google SRE Book (fail-closed vs availability trade-offs). Re-authored/Deepened for this guide.

Refresh-token family state

EventFamily stateAction
User logs infamily-1: token-A (valid)Issue access + refresh A
Refresh A -> new pair Bfamily-1: token-B (valid), token-A (revoked)Issue B, revoke A
Replay stolen token Afamily-1: token-A reused after revocationRevoke entire family-1, force re-login

Storing a family lineage lets the server detect token replay and contain the blast radius of a stolen refresh token.

When NOT to choose each piece

Interviewer follow-ups & drills

  1. Why not put tenant_id only in the path and trust the client? Client can swap IDs; scope from token claims and enforce server-side.
  2. Ops signals: refresh-family reuse events, JWKS fetch failures, PDP latency/error rate, cross-tenant deny rate spikes.
  3. Drill: Access JWT TTL 15m, stolen refresh used after rotation — what must revoke? Entire refresh family; force re-login.
🤖 Don't fully get this? Learn it with Claude

Stuck on Authorization — PKCE, Multi-Tenancy, Zanzibar & Revocation (Deep Dive)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Authorization — PKCE, Multi-Tenancy, Zanzibar & Revocation (Deep Dive)** (System Design) and want to truly understand it. Explain Authorization — PKCE, Multi-Tenancy, Zanzibar & Revocation (Deep Dive) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Authorization — PKCE, Multi-Tenancy, Zanzibar & Revocation (Deep Dive)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Authorization — PKCE, Multi-Tenancy, Zanzibar & Revocation (Deep Dive)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Authorization — PKCE, Multi-Tenancy, Zanzibar & Revocation (Deep Dive)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes