Skip to content

OAuth2 & OIDC Flows

Key Points

  • OAuth2 = delegated authorization (token to access an API). OIDC = authentication built on top (id_token says 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

POST /token
grant_type=refresh_token
refresh_token=...
→ { access_token, refresh_token (rotated) }

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:

  • openid scope must be requested.
  • Returns id_token (a JWT) with sub, name, email, etc.
  • Adds /userinfo endpoint 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)

/authorize?...&resource=https://api.contoso.com

Lets the client tell the IdP which API the token is for. Helps prevent token reuse across audiences.

State and nonce

  • stateCSRF protection on the redirect. Must round-trip and be verified.
  • nonce — replay protection on the id_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

// Old SPA pattern: response_type=token (deprecated)

✅ 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

return Challenge(new AuthenticationProperties { RedirectUri = "/" }, "oidc");

❌ 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-configurationJSON 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 state validationCSRF 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 storageXSS = pwned.

Further reading