Blog
IAM Chapter 6: OAuth 2.0, Part 2

On this page
- The Ladder: Problem, Fix, New Problem
- The Authorization Code Flow, End to End
- The Steps
- Who Holds What at the End
- What if the Code Is Stolen at Step 5?
- Confidential vs. Public Clients
- PKCE: The One-Time Padlock
- The Steps (the PLM Mobile App)
- Why It Works
- Why a New Verifier for Every Login?
- Client Credentials: An App Acting on Its Own
- The Steps (BOM-Sync's Nightly Run)
- Compared with the Old Service-Account Password
- Retired Flows: Why They Existed, Why They Were Dropped
- The Implicit Flow
- The Password Grant
- Grant Types at a Glance
- OAuth 2.1: The Clean-Up
- Attack Points: The Redirect URI and State
- Attack 1: A Rigged Redirect Address
- Attack 2: A Code Nobody Requested
- Why "Redirect URI" and Not "URL"?
- OAuth's Gap: A Token Is Not a Login
- Which Flow for Which App? Two Questions Decide It
- What Breaks OAuth in Practice
- The Parcel Counter: One Picture for Five Concepts
- 🔴 CHECKPOINT: One-Line Takeaways
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
| Step | What it fixes | What breaks next |
|---|---|---|
| Authorization Code flow | John approves, and tokens stay off the browser | Phone apps cannot hide a secret |
| PKCE | A stolen code is worthless, with no stored secret | No human at all (servers) |
| Client Credentials | An app acts as itself | Old shortcuts are still around |
| Retiring old flows | Tokens in the URL, shared passwords | Small checks are still needed |
| Redirect URI + state | Rigged addresses, unrequested codes | A token is not a login |
| The gap | OAuth authorizes, it does not authenticate | A 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
- John clicks "Connect to PLM" on PartVault's page.
- PartVault redirects his browser to Entra ID with the address above.
- John logs in at Entra ID with his password and MFA. PartVault never sees them.
- The consent screen appears. "PartVault wants to read your part drawings." John clicks Allow.
- Entra ID redirects the browser back to PartVault with the one-time authorization code. The browser delivers it to PartVault.
- PartVault's server calls Entra ID directly with the code, client ID, client secret, and redirect URI.
- Entra ID verifies all of it and returns an access token (about 1 hour) and a refresh token (about 90 days).
- PartVault calls PLM's API with the access token.
- PLM checks the token (signature, expiry, scope) and returns the drawings.
- Later, the access token expires. PartVault sends the refresh token to Entra ID and gets a new access token. John never knows.
Who Holds What at the End
| Party | Holds | Never sees |
|---|---|---|
| John | his password | any token |
| PartVault | access token, refresh token, client secret | John's password |
| Entra ID | the consent record, issued tokens | n/a |
| PLM API | only the access token shown to it | John'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 client | Public client | |
|---|---|---|
| Can hide a secret? | Yes | No |
| Client secret | Used | None (it would be worthless) |
| Example | PartVault backend | PLM 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)
- John taps "Sign in." The app invents a random code verifier and keeps it in memory.
- The app computes the code challenge = hash(verifier).
- The app opens the phone's browser at Entra ID with the usual parameters plus the challenge. The verifier is not sent.
- John logs in and clicks Allow.
- Entra ID stores the challenge alongside the one-time code it issues.
- Entra ID redirects to
plm://callbackwith the code, and the phone hands it to the app. - The app calls Entra ID directly with the code + client ID + redirect URI + the original verifier. It sends no secret, because it has none.
- Entra ID hashes the verifier, compares it with the stored challenge, and issues tokens only on a match.
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 what | Verifier | Challenge |
|---|---|---|
| PLM mobile app | Yes (invented it) | Yes |
| Entra ID | Only at step 7 | Yes (from step 3) |
| Browser, other apps | No | Yes (in the address) |
| Malicious app | No | Maybe |
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 secret | PKCE verifier | |
|---|---|---|
| Created | once, at registration | fresh every login |
| Lives | on a server, long-term | in app memory, for minutes |
| In the app package? | no (server only) | never |
| For | confidential clients | public 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)
- One-time setup: an admin registers BOM-Sync and approves the permission it needs (for example
drawings.readon PLM's API). That approval replaces the consent screen. - Each night, BOM-Sync's server sends its client ID, secret, and scope directly to Entra ID.
- Entra ID checks them against the approved permissions and returns an access token (about 1 hour).
- BOM-Sync calls PLM's API with the token.
- PLM checks the token (signature, expiry, scope) and returns the data.
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 holds | a long-lived password that works everywhere | a secret that only fetches short-lived tokens |
| Access level | whatever the account can do | only the scopes the admin approved |
| What goes to the API | the same password, every time | a token that expires in about an hour |
| Audit trail | looks like a shared account | names the app |
| Revoke | change a password many things use | disable 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 type | Used when | Status |
|---|---|---|
| Authorization Code (+ PKCE) | a user is involved | current |
| Client Credentials | no user, app acts as itself | current |
| Refresh Token | renew an access token | current |
| Implicit | browser-only apps | retired |
| Password | app collects the password | retired |
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.
| Change | Why |
|---|---|
| implicit and password grants removed | token in the front channel; defeats delegation |
| PKCE required for the code flow | stops stolen codes, for all clients |
| exact redirect URI matching | stops attackers redirecting the code |
| no tokens in the address | stops 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.
- An attacker builds a link that looks like the real PartVault sign-in but says
redirect_uri=https://evil.com/steal. - John clicks it, logs in at the real Entra ID page, and clicks Allow.
- 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.
- The attacker starts their own PartVault connection, logs in as themselves, and stops at their own code.
- They trick John's browser into visiting PartVault's callback address with that code.
- 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.
| Client | Redirect target | Is it a URL? |
|---|---|---|
| PartVault server | https://partvault.com/callback | Yes |
| PLM mobile app | plm://callback | No, it names an app |
| Desktop tool | http://127.0.0.1:8400/callback | A 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:
- It was issued for PLM's API, so nothing in it names ExpenseApp: there's no audience.
- It's a bearer token, so anyone holding it can present it.
- 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 token | What a login needs | |
|---|---|---|
| What the app may do | Yes | Not needed |
| Who logged in | Not standardised | Yes |
| Made for a specific app | No, made for the API | Yes (audience) |
| When and how authenticated | No | Yes |
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
- Is a user involved? No: Client Credentials. Yes: ask question 2.
- Can the app keep a secret? Yes (a server): Code flow (PKCE too is recommended). No (phone, desktop, browser app): Code + PKCE.
- Never use the implicit flow or the password grant.
| Skyline app | User involved? | Can keep a secret? | Flow |
|---|---|---|---|
| PartVault (third-party web app) | Yes, John | Yes (its server) | Authorization Code |
| PLM mobile app | Yes, John | No | Authorization Code + PKCE |
| BOM-Sync nightly job | No | Yes (its server) | Client Credentials |
| Old travel page (token in the URL) | Yes | No | implicit today, move to Code + PKCE |
| Internal tool asking for passwords | Yes | n/a | password grant today, rebuild with the code flow |
What Breaks OAuth in Practice
| Mistake | What goes wrong | Fix |
|---|---|---|
| Loose or wildcard redirect URI | The code is delivered to the attacker | Exact match on the registered address |
| Client secret built into a phone app | Anyone unpacks the app and reads it | Treat it as a public client: no secret, use PKCE |
| Public client without PKCE | A malicious app exchanges the stolen code | PKCE on every login |
| No state check | An attacker's code is accepted as John's | Random state per login, checked on return |
| Long-lived access tokens | A stolen bearer token means a long breach | About 1 hour, renew with a refresh token |
| Refresh token kept somewhere exposed | A thief keeps minting new access tokens | Store it safely, rotate it on every use |
| Over-broad scopes | The app can do far more than it needs | Request and approve the minimum |
| Using implicit or password grant | Token in the URL, or the password exposed | Code flow + PKCE, login on Entra ID's page |
| Client secret left in config, never rotated | A leaked secret yields tokens until changed | Store safely, rotate on a schedule |
| Accepting an access token as a login | QuickTools replays John's token at ExpenseApp | Use 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.
| Concept | The parcel-counter version |
|---|---|
| Confidential vs. public | A 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. |
| PKCE | The 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 Credentials | The 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 flows | Implicit: the parcel is handed to whoever stands in the open doorway. Password grant: you give the courier your house keys and ID. |
| Attack points | Redirect 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
statestop 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.