OAuth2 & OIDC Flows
Key Points
- OAuth2 = delegated authorization (token to access an API). OIDC = authentication built on top (
id_tokensays who the user is). - Five common flows: Authorization Code + PKCE (most apps), Client Credentials (service-to-service), Device Code (TVs/CLIs), Resource Owner Password Credentials (legacy; avoid), Implicit (deprecated).
- PKCE everywhere now. Required for SPAs/mobile; recommended even for confidential clients. Mitigates code interception.
- Tokens:
access_token(call APIs),id_token(user identity, JWT),refresh_token(mint new access tokens). - Front channel (browser redirects) carries auth code; back channel (server-to-IdP) carries tokens. Never put tokens through front channel.
- Scopes = what the token can do. Audience (
aud) = which API. Both matter.
Concepts (deep dive)
Roles
┌──────────┐ ┌─────────────┐ ┌────────────────┐ ┌──────────┐
│ Resource │───▶│ Client │───▶│ Authorization │ │ Resource │
│ Owner │ │ (your app) │ │ Server │ │ Server │
│ (user) │ │ │ │ (IdP / OAuth) │ │ (API) │
└──────────┘ └─────────────┘ └────────────────┘ └──────────┘
Client requests authorization from the user, gets a token from the auth server, presents it to the resource server (API).
Authorization Code + PKCE (the default)
1. Browser → Client app → "log in"
2. Client redirects to IdP:
/authorize?response_type=code&client_id=X&redirect_uri=Y
&scope=openid+profile+api.read
&code_challenge=<sha256(verifier)>&code_challenge_method=S256
&state=<csrf-nonce>
3. User authenticates at IdP, consents
4. IdP redirects to Y with ?code=ABC&state=...
5. Client (back-channel) POSTs to /token:
grant_type=authorization_code&code=ABC
&client_id=X&code_verifier=<original>
6. IdP returns { access_token, id_token, refresh_token }
7. Client stores tokens; uses access_token to call API
PKCE (Proof Key for Code Exchange): - Client generates random code_verifier. - Sends code_challenge = sha256(verifier) in /authorize. - Sends code_verifier in /token. - IdP verifies sha256(verifier) == code_challenge.
Even if attacker intercepts the auth code, they don't have the verifier — can't redeem.
Client Credentials (service-to-service)
POST /token
grant_type=client_credentials
client_id=service-A&client_secret=...
scope=api.read
→ { access_token }
No user. Service A authenticates to IdP, gets a token, calls service B. Used for daemons, scheduled jobs, microservice-to-microservice.
Device Code (TVs, CLI tools)
1. CLI requests device code: /devicecode → { device_code, user_code, verification_uri }
2. CLI displays: "Visit https://login.contoso.com/device and enter ABCD-1234"
3. User opens browser on phone, enters code, authenticates
4. CLI polls /token with device_code until success
Common for dotnet, az, gh CLIs.
Refresh tokens
Refresh tokens are long-lived; access tokens short. The IdP rotates the refresh token on use (best practice) — old one is invalidated, and reuse of an invalidated token revokes the whole token family.
The lifetime tuning, rotation mechanics, reuse-detection logic, storage trade-offs (httpOnly cookie vs localStorage vs BFF), and revocation endpoints (RFC 7009 / 7662) have their own deep-dive: see Refresh Token Rotation & Revocation.
Deprecated / discouraged flows
- Implicit flow — tokens via URL fragment. Vulnerable to token leakage. Replaced by Auth Code + PKCE for SPAs.
- Resource Owner Password Credentials (ROPC) — client collects username/password, sends to IdP. Defeats SSO, MFA, federation. Avoid.
OIDC additions
OIDC = OAuth2 + identity layer:
openidscope must be requested.- Returns
id_token(a JWT) withsub,name,email, etc. - Adds
/userinfoendpoint for additional claims. - Discovery:
{authority}/.well-known/openid-configuration— endpoints, supported flows, JWKS URI.
Configuration in ASP.NET Core
builder.Services.AddAuthentication(o =>
{
o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(o =>
{
o.Authority = "https://login.microsoftonline.com/{tenant}/v2.0";
o.ClientId = "...";
o.ClientSecret = builder.Configuration["AzureAd:ClientSecret"];
o.ResponseType = OpenIdConnectResponseType.Code;
o.UsePkce = true;
o.SaveTokens = true;
o.Scope.Clear();
o.Scope.Add("openid");
o.Scope.Add("profile");
o.Scope.Add("offline_access"); // get refresh token
o.Scope.Add("api://my-api/access");
o.GetClaimsFromUserInfoEndpoint = true;
o.TokenValidationParameters.NameClaimType = "name";
o.TokenValidationParameters.RoleClaimType = "roles";
});
Calling APIs with the access token
public class ApiClient(IHttpClientFactory hf, IHttpContextAccessor http)
{
public async Task<T> GetAsync<T>(string url)
{
var client = hf.CreateClient("api");
var token = await http.HttpContext!.GetTokenAsync("access_token");
client.DefaultRequestHeaders.Authorization = new("Bearer", token);
return await client.GetFromJsonAsync<T>(url) ?? throw new();
}
}
Token caching and refresh
When SaveTokens = true, ASP.NET stores tokens in the auth cookie. On expiry, you can refresh via the refresh token. Use libraries like Duende.AccessTokenManagement or IdentityModel.AspNetCore to automate refresh.
Resource Indicators (RFC 8707)
Lets the client tell the IdP which API the token is for. Helps prevent token reuse across audiences.
State and nonce
state— CSRF protection on the redirect. Must round-trip and be verified.nonce— replay protection on theid_token. Tied to the auth request.
ASP.NET handles both automatically when configured correctly.
Scopes vs roles vs permissions
- Scope — what the client is allowed to ask for ("read calendar"). Coarse-grained.
- Role / permission claim — what the user can do. Fine-grained.
A common mistake: treating scope as authorization. "User has scope api.read" doesn't mean they can read anything; check user-level claims too.
Code: correct vs wrong
❌ Wrong: implicit flow for SPA
✅ Correct: Auth Code + PKCE
// oidc-client-ts library (or BFF — even better)
const userManager = new UserManager({
authority: "https://idp.example.com",
client_id: "spa",
redirect_uri: "https://app/callback",
response_type: "code",
scope: "openid profile api.read"
});
❌ Wrong: ROPC for first-party app
var token = await idp.PostAsync("/token",
new { grant_type = "password", username, password }); // bypasses MFA
✅ Correct: Auth Code + PKCE with browser
❌ Wrong: missing state validation
Custom OAuth client without state round-trip is CSRF-vulnerable.
✅ Correct: built-in AddOpenIdConnect handles state/nonce
Design patterns for this topic
Pattern 1 — "Auth Code + PKCE always"
- Intent: PKCE even for confidential clients; defense in depth.
Pattern 2 — "BFF over SPA-direct OAuth"
- Intent: SPA never holds tokens; backend mediates.
Pattern 3 — "Client Credentials for daemons"
- Intent: machine-to-machine without user.
Pattern 4 — "Refresh-token rotation"
- Intent: detect refresh-token theft.
Pattern 5 — "Audience-scoped tokens"
- Intent: one token per API; no reuse across services.
Pros & cons / trade-offs
| Flow | Pros | Cons |
|---|---|---|
| Auth Code + PKCE | Secure; widely supported | Browser redirect required |
| Client Credentials | Simple; no user | Only for service identity |
| Device Code | Works on TVs/CLIs | UX requires second device |
| ROPC | Simple | No MFA/SSO; deprecated |
| Implicit | (none — deprecated) | Token leakage via URL |
When to use / when to avoid
- Web app + user → Auth Code + PKCE (cookie session via BFF).
- SPA → Auth Code + PKCE via BFF; avoid SPA-direct.
- Mobile → Auth Code + PKCE via system browser (
AuthenticationServices/Custom Tabs). - Service-to-service → Client Credentials.
- CLI/TV → Device Code.
- Avoid ROPC, Implicit.
Interview Q&A
Q1. Difference between OAuth2 and OIDC? OAuth2 is authorization (access tokens for APIs). OIDC adds identity (id_token JWT) on top — proves who logged in.
Q2. Why PKCE? Mitigates auth-code interception. Even if attacker steals the code, they don't have the verifier — can't redeem the token. Originally for mobile/SPA; now best practice everywhere.
Q3. When use Client Credentials? Service-to-service. No user. The service authenticates to the IdP with its own credentials and gets an app-level token.
Q4. Why is Implicit deprecated? Tokens were returned in URL fragments — leaked via referrer headers, browser history, server logs. PKCE + Auth Code is the safe replacement.
Q5. What's state for? CSRF protection on redirect. Client generates random state, sends in /authorize, verifies it returns unchanged.
Q6. What's nonce for? Binds an id_token to a specific request. Prevents replay.
Q7. Difference between scope and role? Scope: what the client can ask for. Role/permission: what the user can do. Authorize on both.
Q8. What does offline_access give you? Refresh token, so you can mint new access tokens without re-prompting the user.
Q9. Why rotate refresh tokens? Detect theft. If both old and new are presented, IdP suspects compromise.
Q10. What's discovery? {authority}/.well-known/openid-configuration — JSON document with all endpoints (auth, token, JWKS, userinfo). Lets clients auto-configure.
Q11. Should SPAs use refresh tokens? Tricky. Refresh-token rotation + short access-token lifetime is OK if backed by HttpOnly cookies; better is BFF.
Q12. Why use Resource Indicators (RFC 8707)? Tells IdP which API the token's for. Prevents the IdP from issuing a single token usable across multiple APIs.
Gotchas / common mistakes
- ⚠️ Skipping PKCE on confidential clients — defense in depth missed.
- ⚠️ No
statevalidation — CSRF on redirect. - ⚠️ Tokens in URL fragments — browser history leak.
- ⚠️ Too-broad scopes (
api.*) — lateral movement risk. - ⚠️ No refresh-token rotation — silent persistent compromise.
- ⚠️ Storing tokens client-side in JS-readable storage — XSS = pwned.