Skip to content
Hameed's dev blog

Blog

IAM Chapter 6: OAuth 2.0, Part 2

·23 min read·---
Spider web.jpg
On this page

Part of the IAM study series.

Chapter 5 solved the delegation problem: how an app can act for a user without ever holding the user's password. The answer was a token with limited scope and a short life. This chapter covers what comes next: which flow each kind of app should use, how each one is kept safe, and where OAuth stops being the right tool.

Every concept here is built on the one before it, so read it in order — each one exists because the previous one broke.

The Ladder: Problem, Fix, New Problem

StepWhat it fixesWhat breaks next
Authorization Code flowJohn approves, and tokens stay off the browserPhone apps cannot hide a secret
PKCEA stolen code is worthless, with no stored secretNo human at all (servers)
Client CredentialsAn app acts as itselfOld shortcuts are still around
Retiring old flowsTokens in the URL, shared passwordsSmall checks are still needed
Redirect URI + stateRigged addresses, unrequested codesA token is not a login
The gapOAuth authorizes, it does not authenticateA future OIDC chapter

The Authorization Code Flow, End to End

What it is. The Authorization Code flow is the main OAuth flow when a user is involved. John approves access in his browser, and then the app's server collects the tokens directly from Entra ID. It's named after the one-time authorization code at its centre.

Why it exists. John must approve what PartVault (a third-party parts catalogue app) can do. But neither his password nor the valuable tokens may pass through the exposed browser. So the browser carries only a harmless one-time code, and the tokens travel server to server.

Before anything runs (registration, done once). PartVault is registered in Entra ID and receives:

  • a client ID (pv-app-001): public, an identifier
  • a client secret: private, stored only on PartVault's server
  • a redirect URI (https://partvault.com/callback): the one address the code may be sent back to

PLM's API has also defined its scopes: drawings.read, drawings.write, export.read, and so on.

The request John triggers. It's just an address with four parameters, and nothing in it is secret:

https://login.microsoftonline.com/.../authorize
  ?client_id=pv-app-001
  &scope=drawings.read
  &redirect_uri=https://partvault.com/callback
  &response_type=code
  • client_id: "I am PartVault"
  • scope: "I only want this permission"
  • redirect_uri: "send John back here" (it must match the registered one)
  • response_type=code: "give me a code, not the token"

Read it as two halves. Steps 1 to 5 are John proving and approving (front channel). Steps 6 to 10 are servers doing the work (back channel).

The Steps

  1. John clicks "Connect to PLM" on PartVault's page.
  2. PartVault redirects his browser to Entra ID with the address above.
  3. John logs in at Entra ID with his password and MFA. PartVault never sees them.
  4. The consent screen appears. "PartVault wants to read your part drawings." John clicks Allow.
  5. Entra ID redirects the browser back to PartVault with the one-time authorization code. The browser delivers it to PartVault.
  6. PartVault's server calls Entra ID directly with the code, client ID, client secret, and redirect URI.
  7. Entra ID verifies all of it and returns an access token (about 1 hour) and a refresh token (about 90 days).
  8. PartVault calls PLM's API with the access token.
  9. PLM checks the token (signature, expiry, scope) and returns the drawings.
  10. Later, the access token expires. PartVault sends the refresh token to Entra ID and gets a new access token. John never knows.
Rendering diagram…

Who Holds What at the End

PartyHoldsNever sees
Johnhis passwordany token
PartVaultaccess token, refresh token, client secretJohn's password
Entra IDthe consent record, issued tokensn/a
PLM APIonly the access token shown to itJohn's password, refresh token

What if the Code Is Stolen at Step 5?

A browser extension copies the code from the address. It still needs the client secret, and the code is single-use, expires in minutes, and is bound to the registered redirect URI. The client ID is public, so it isn't the missing piece.

The catch: this protection assumes the secret really is secret. That's true for a server and false for a phone app — which brings us to the next concept.

Confidential vs. Public Clients

Why it matters. The code flow is safe because the client secret stays secret. So the real question is: can this app keep a secret? OAuth splits clients into two types on exactly that.

  • Confidential client: can keep a secret and proves itself with it. It runs on a server you control, like PartVault's backend.
  • Public client: cannot keep a secret, so it needs another proof. It runs on the user's device or in the browser, like Skyline's PLM mobile app.

Why a phone app cannot hide a secret. The secret would sit inside the app package that every employee downloads. Anyone can unpack it with free tools, and it's identical in every copy — like printing the company's signatory ID on every visitor badge.

The gap this opens. A malicious app on the same phone can register the same plm://callback address and catch the authorization code. The exchange needs only the client ID (public), the code (caught), and the redirect URI (public). Nothing stops it.

Confidential clientPublic client
Can hide a secret?YesNo
Client secretUsedNone (it would be worthless)
ExamplePartVault backendPLM mobile app, desktop tool

PKCE: The One-Time Padlock

What it is. PKCE (Proof Key for Code Exchange, said "pixie," RFC 7636) is a fresh proof that the app finishing the login is the same app that started it.

The problem. There's no stored secret, so we need a secret that's created fresh for each login and never has to live inside the app package.

The idea. The app invents a secret on the spot. At the start it shows Entra ID only a fingerprint of it. At the end it reveals the original, and Entra ID checks that it matches. It's like handing over a padlock at the start and showing the key at the end.

The vocabulary:

  • Hash: a one-way fingerprint. Easy to compute from a value, impossible to reverse.
  • Code verifier: a random secret invented for one login only and kept in memory (the key).
  • Code challenge: the hash of the verifier, sent at the start (the padlock). The app asks for it with code_challenge_method=S256, meaning "the challenge is a SHA-256 hash."

The Steps (the PLM Mobile App)

  1. John taps "Sign in." The app invents a random code verifier and keeps it in memory.
  2. The app computes the code challenge = hash(verifier).
  3. The app opens the phone's browser at Entra ID with the usual parameters plus the challenge. The verifier is not sent.
  4. John logs in and clicks Allow.
  5. Entra ID stores the challenge alongside the one-time code it issues.
  6. Entra ID redirects to plm://callback with the code, and the phone hands it to the app.
  7. The app calls Entra ID directly with the code + client ID + redirect URI + the original verifier. It sends no secret, because it has none.
  8. Entra ID hashes the verifier, compares it with the stored challenge, and issues tokens only on a match.
Rendering diagram…

Why It Works

A malicious app has the code and may have seen the challenge in the address. It cannot work back to the verifier, because a hash cannot be reversed. Any guess hashes to something different from the stored challenge, so Entra ID rejects it. The real app sends the true verifier, and it matches.

Who knows whatVerifierChallenge
PLM mobile appYes (invented it)Yes
Entra IDOnly at step 7Yes (from step 3)
Browser, other appsNoYes (in the address)
Malicious appNoMaybe

Why a New Verifier for Every Login?

Suppose the app used one fixed verifier built into it. That value would sit in the downloadable package, readable and reusable forever — the original problem again. A fresh verifier lives only in memory for minutes, so there's nothing stored to extract.

Client secretPKCE verifier
Createdonce, at registrationfresh every login
Liveson a server, long-termin app memory, for minutes
In the app package?no (server only)never
Forconfidential clientspublic clients (and all)

Pros: no secret to hide in an app anyone can download, a fresh proof every time, and a stolen code is worthless. Cons: it only ties one login's start to its end — it doesn't prove which app you are, so anyone can claim the client ID. Today: PKCE is recommended for every client, not just phone apps, and OAuth 2.1 requires it for the code flow.

Client Credentials: An App Acting on Its Own

The problem. This is the second thing SAML couldn't do. Skyline's nightly job BOM-Sync reads every drawing's data from PLM and pushes it to the finance system. Nobody is awake to log in. With SAML the only option was a service account password in a config file: shared, rarely changed, and indistinguishable from a person in the logs.

What changes. Everything so far assumed a user, John, who logs in and clicks Allow. Here there's no John: no browser, no consent screen, no redirects, no code. The app is acting for itself (the resource owner is effectively Skyline).

What it is. The Client Credentials flow is where the app proves itself with its client ID and client secret and gets an access token for its own permissions. No user is involved.

The Steps (BOM-Sync's Nightly Run)

  1. One-time setup: an admin registers BOM-Sync and approves the permission it needs (for example drawings.read on PLM's API). That approval replaces the consent screen.
  2. Each night, BOM-Sync's server sends its client ID, secret, and scope directly to Entra ID.
  3. Entra ID checks them against the approved permissions and returns an access token (about 1 hour).
  4. BOM-Sync calls PLM's API with the token.
  5. PLM checks the token (signature, expiry, scope) and returns the data.
Rendering diagram…

Why no refresh token? The app can prove itself with its own secret at any moment, so when the token expires it simply asks again. A refresh token exists to spare a user another login. There's no user, and it would only be one more long-lived secret to protect.

The token represents the app, not a person. PLM's audit log shows "BOM-Sync," so the log can finally tell the app apart from any human — the PartVault blind-audit problem from Chapter 5 is solved.

Compared with the Old Service-Account Password

Service account password (SAML era)Client Credentials
App holdsa long-lived password that works everywherea secret that only fetches short-lived tokens
Access levelwhatever the account can doonly the scopes the admin approved
What goes to the APIthe same password, every timea token that expires in about an hour
Audit traillooks like a shared accountnames the app
Revokechange a password many things usedisable this one app registration

Cons: it still relies on a client secret, so store it safely and rotate it. Permissions are often broad, so keep scopes minimal. It's for confidential clients only (servers), because a phone app cannot hold the secret. A leaked secret yields tokens until it's changed.

Retired Flows: Why They Existed, Why They Were Dropped

Grant type (or flow) = a method a client uses to obtain an access token. So far we have three current ones: Authorization Code (+ PKCE), Client Credentials, and Refresh Token.

Why were there ever others? Each grant type was built around what apps could do at the time. Two of them were shortcuts for limits that no longer exist, and each shortcut reopened a problem OAuth was created to solve.

The Implicit Flow

  • The limit of its era: a browser-only app (a single-page app) had no server to hide a secret in, and early browsers couldn't make the direct server call that the code flow needs.
  • The shortcut: skip the code and return the access token straight to the app in the address, through the browser (front channel).
  • Why it was dropped: a token in the address leaks through browser history, logs, extensions, and referrer headers, and a leaked bearer token is a usable key. There's no refresh token and no way to tie the token to the app that asked for it. It broke the rule that valuable things stay on the back channel.
  • Replaced by: Authorization Code + PKCE, since modern browsers can now make the direct call.

The Password Grant

  • The limit of its era: early on, and for old apps being migrated, redirecting the user to a separate login page was seen as awkward.
  • The shortcut: the app shows its own login form, collects John's username and password, and sends them to Entra ID in exchange for a token.
  • Why it was dropped: it's the password anti-pattern from Chapter 5 again. The app sees John's password, which is exactly what delegation was meant to prevent. It also can't handle MFA, SSO, or passkeys, and it trains users to type their password into any app that asks.
  • Replaced by: the code flow, where John logs in on Entra ID's own page and the app never sees the password.

Grant Types at a Glance

Grant typeUsed whenStatus
Authorization Code (+ PKCE)a user is involvedcurrent
Client Credentialsno user, app acts as itselfcurrent
Refresh Tokenrenew an access tokencurrent
Implicitbrowser-only appsretired
Passwordapp collects the passwordretired

OAuth 2.1: The Clean-Up

OAuth 2.1 is an IETF effort to fold the lessons of the 2.0 years into one specification. It adds no new protocol, and as of this writing is still a draft — check its current status before quoting it.

ChangeWhy
implicit and password grants removedtoken in the front channel; defeats delegation
PKCE required for the code flowstops stolen codes, for all clients
exact redirect URI matchingstops attackers redirecting the code
no tokens in the addressstops leaks through the browser

Skyline: an old travel-booking page that leaves its token in the address bar must move to Code + PKCE. An internal tool that asks engineers for their PLM password to fetch a token must be rebuilt, because it breaks MFA and exposes the password.

Attack Points: The Redirect URI and State

OAuth is only as safe as its details. Two small checks stop two real attacks, both on steps we already know.

Attack 1: A Rigged Redirect Address

Suppose Entra ID accepted any redirect address.

  1. An attacker builds a link that looks like the real PartVault sign-in but says redirect_uri=https://evil.com/steal.
  2. John clicks it, logs in at the real Entra ID page, and clicks Allow.
  3. Entra ID sends the authorization code to evil.com. The attacker now holds a valid code from a real login.

Defence: exact matching. Entra ID compares the redirect_uri in the request with the registered one, character for character. No wildcards, no "starts with." A different address is rejected before John even sees a login page.

Attack 2: A Code Nobody Requested

This one targets the return trip at step 5.

  1. The attacker starts their own PartVault connection, logs in as themselves, and stops at their own code.
  2. They trick John's browser into visiting PartVault's callback address with that code.
  3. PartVault sees a valid code, completes the exchange, and links the attacker's PLM account to John's session. PartVault never asked a question, yet it accepted an answer.

Defence: state. Before step 2, PartVault invents a random value per login and stores it in John's session. It sends it at step 2, Entra ID returns it unchanged at step 5, and PartVault checks that it matches. No match, or missing: reject.

state is a random per-login value the client sends out and checks on return, to confirm the response answers its own request. It's the same idea as SAML's request ID from Chapter 4: PLM sent REQ-7742, and the assertion had to come back with InResponseTo: REQ-7742. A response nobody asked for gets rejected. It must be random and different each login, or an attacker could learn one value and reuse it. PKCE gives the same protection today, because the verifier is tied to the app that started the login, but state is still standard practice.

Why "Redirect URI" and Not "URL"?

A URI is any string that identifies a resource. A URL is a URI that also says where to find it. Every URL is a URI, not the reverse.

ClientRedirect targetIs it a URL?
PartVault serverhttps://partvault.com/callbackYes
PLM mobile appplm://callbackNo, it names an app
Desktop toolhttp://127.0.0.1:8400/callbackA local address

That's why OAuth says "redirection URI": the target is not always a web address.

OAuth's Gap: A Token Is Not a Login

The temptation. Apps soon wanted OAuth for plain login too ("Sign in with ..."). A new Skyline app, ExpenseApp, reasons: "someone arrived with a valid token that John approved, so John must be the user."

The attack. A malicious app, QuickTools, tricked John into connecting earlier and kept his token. It presents that token to ExpenseApp, which lets it in as John.

Why the token cannot be a login:

  1. It was issued for PLM's API, so nothing in it names ExpenseApp: there's no audience.
  2. It's a bearer token, so anyone holding it can present it.
  3. It has no standard statement of when or how John authenticated (was MFA used?). Each provider invented its own "who am I?" call.

Hotel analogy. The access token is the key card — it opens room 407, but it isn't ID. A bank needs a document stating who you are, who issued it, which bank it's for, and when you were checked.

OAuth access tokenWhat a login needs
What the app may doYesNot needed
Who logged inNot standardisedYes
Made for a specific appNo, made for the APIYes (audience)
When and how authenticatedNoYes

Link to SAML. The SAML assertion had WHO, WHEN/HOW, and an audience. OAuth has no equivalent.

Authentication = proving who someone is. Authorization = deciding what they may do. OAuth 2.0 handles authorization only, and using its token as proof of login is a known mistake.

The fix. An identity layer on top of OAuth: OIDC (OpenID Connect). It's the same flow with one extra output — a topic for a future chapter in this series.

Which Flow for Which App? Two Questions Decide It

  1. Is a user involved? No: Client Credentials. Yes: ask question 2.
  2. Can the app keep a secret? Yes (a server): Code flow (PKCE too is recommended). No (phone, desktop, browser app): Code + PKCE.
  3. Never use the implicit flow or the password grant.
Skyline appUser involved?Can keep a secret?Flow
PartVault (third-party web app)Yes, JohnYes (its server)Authorization Code
PLM mobile appYes, JohnNoAuthorization Code + PKCE
BOM-Sync nightly jobNoYes (its server)Client Credentials
Old travel page (token in the URL)YesNoimplicit today, move to Code + PKCE
Internal tool asking for passwordsYesn/apassword grant today, rebuild with the code flow

What Breaks OAuth in Practice

MistakeWhat goes wrongFix
Loose or wildcard redirect URIThe code is delivered to the attackerExact match on the registered address
Client secret built into a phone appAnyone unpacks the app and reads itTreat it as a public client: no secret, use PKCE
Public client without PKCEA malicious app exchanges the stolen codePKCE on every login
No state checkAn attacker's code is accepted as John'sRandom state per login, checked on return
Long-lived access tokensA stolen bearer token means a long breachAbout 1 hour, renew with a refresh token
Refresh token kept somewhere exposedA thief keeps minting new access tokensStore it safely, rotate it on every use
Over-broad scopesThe app can do far more than it needsRequest and approve the minimum
Using implicit or password grantToken in the URL, or the password exposedCode flow + PKCE, login on Entra ID's page
Client secret left in config, never rotatedA leaked secret yields tokens until changedStore safely, rotate on a schedule
Accepting an access token as a loginQuickTools replays John's token at ExpenseAppUse OIDC for login

The Parcel Counter: One Picture for Five Concepts

If the sections above felt like a lot, here's the same material as one analogy. The code is the collection slip, the client secret is the company's authorised-signatory stamp, and the token is the parcel.

ConceptThe parcel-counter version
Confidential vs. publicA company back-office keeps its stamp in a safe. A courier on the street carries everything in his pockets, so anyone can copy his stamp.
PKCEThe courier makes a one-time padlock for each pickup: he hands the counter the padlock (challenge) and keeps the key (verifier). At collection he shows the key. A thief with the slip has no key. It's new every time, so there's nothing stored to copy.
Client CredentialsThe company's own warehouse robot collects its own stock at night: no customer, no slip, only the company stamp, and a day pass. The pass expires, so it shows the stamp again. No "voucher" needed.
Retired flowsImplicit: the parcel is handed to whoever stands in the open doorway. Password grant: you give the courier your house keys and ID.
Attack pointsRedirect URI: deliver only to the exact registered address. State: accept only deliveries carrying your own order reference.

🔴 CHECKPOINT: One-Line Takeaways

  • Authorization Code flow: John approves in the browser (front channel), then the app's server collects the tokens directly (back channel).
  • The code is a one-time claim ticket. It travels through the browser, so it's useless without the client secret.
  • Confidential clients can keep a secret (servers). Public clients cannot (phone, desktop, and browser apps).
  • PKCE replaces the stored secret with a fresh padlock and key for every login.
  • Client Credentials is for an app acting as itself. There's no user, no refresh token, and the audit log names the app.
  • Implicit and password grants are retired. Each was a shortcut that reopened the problem OAuth was built to solve.
  • Exact redirect URIs and state stop a rigged address and an unrequested code.
  • A token is not a login. OAuth authorizes, and OIDC adds identity on top.

Sources: RFC 6749 (The OAuth 2.0 Authorization Framework), RFC 7636 (Proof Key for Code Exchange), the IETF OAuth 2.1 draft — check its current status before quoting it.


← Previous: OAuth 2.0, Part 1 · Back to the series start

Practical notes on product management, distributed systems, and AI—written from real engineering and product experience.