OAuth 2.0 Flows Explained — Which One to Use and Why
The four OAuth 2.0 flows plus PKCE, which are deprecated and why, scopes, token types, and a real Authorization Code + PKCE walkthrough for a React SPA calling a .NET API.
Why OAuth 2.0 Exists
Before OAuth, the way to let a third-party app access your data was to give it your username and password. That's catastrophic. OAuth 2.0 is an authorization framework that lets a user grant limited access to their account without sharing credentials.
The spec defines several "flows" (called grant types) for different scenarios. Choosing the wrong one is a common security mistake.
The Token Types
Before flows, understand what you're getting:
Access token — proves authorization. Short-lived (5–60 min). Sent in every API request as Authorization: Bearer <token>.
Refresh token — used to get a new access token. Longer-lived (hours to days). Never sent to the API. Stored securely, rotated on use.
ID token — an OpenID Connect addition. A JWT about the authenticated user. Used by the client app, not the API. Contains name, email, picture, etc.
Flow 1 — Authorization Code (Classic Web Apps)
For server-side web apps where the server can keep a secret.
1. User clicks "Login"
2. App redirects to auth server with:
?response_type=code
&client_id=...
&redirect_uri=https://app.example.com/callback
&scope=openid profile
&state=
3. User authenticates at auth server
4. Auth server redirects back: /callback?code=AUTH_CODE&state=...
5. Server validates state, then exchanges code for tokens:
POST /token
grant_type=authorization_code
&code=AUTH_CODE
&client_id=...
&client_secret=... ← server-side only
6. Auth server returns access_token + refresh_token + id_token The state parameter prevents CSRF. The code exchange happens server-side so the client_secret is never exposed.
Flow 2 — Authorization Code + PKCE (The Current Standard)
PKCE (Proof Key for Code Exchange, pronounced "pixy") was invented for mobile apps that can't keep a client secret. It's now the recommended flow for all new clients — SPAs, mobile, and even web apps.
PKCE adds a code_verifier (random 64-byte value) and code_challenge (SHA-256 hash of the verifier) to prove the app that started the flow is the same one exchanging the code.
// React SPA — Authorization Code + PKCE
function generateCodeVerifier(): string {
const array = new Uint8Array(64);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function startLogin() {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// Store verifier — needed for exchange step
sessionStorage.setItem('code_verifier', verifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'my-spa',
redirect_uri: 'https://app.example.com/callback',
scope: 'openid profile api:read',
state: crypto.randomUUID(),
code_challenge: challenge,
code_challenge_method: 'S256'
});
window.location.href = `https://auth.example.com/authorize?${params}`;
}Callback handler:
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const verifier = sessionStorage.getItem('code_verifier');
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code!,
redirect_uri: 'https://app.example.com/callback',
client_id: 'my-spa',
code_verifier: verifier! // Auth server verifies hash matches
})
});
const { access_token, refresh_token, id_token } = await response.json();
// Store access_token in memory, refresh_token in HttpOnly cookie via your own server
}The .NET API receiving these tokens:
// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://auth.example.com";
options.Audience = "api.example.com";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidAlgorithms = new[] { "RS256" }
};
});
// Controller
[Authorize]
[HttpGet("profile")]
public IActionResult GetProfile()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var scopes = User.FindFirst("scope")?.Value?.Split(' ') ?? [];
if (!scopes.Contains("api:read"))
return Forbid();
return Ok(new { UserId = userId });
}Flow 3 — Client Credentials (Machine-to-Machine)
No user involved. A backend service authenticates as itself to call another service.
POST /token
grant_type=client_credentials
&client_id=service-a
&client_secret=...
&scope=reports:readReturns an access token. No refresh token — just re-authenticate when expired. Use this for microservice-to-microservice calls, background jobs calling APIs, CI/CD pipelines.
// ASP.NET Core — calling a downstream API with client credentials
services.AddHttpClient("ReportService")
.AddClientCredentialsTokenHandler(); // from Duende.AccessTokenManagement
// The library handles token caching and renewal automaticallyFlow 4 — Device Authorization (Smart TVs, CLIs)
For devices with limited input (no browser, no keyboard). The device shows a short code, user visits a URL on their phone to authorize.
1. Device: POST /device_authorization
→ response: { device_code, user_code: "GFKJ-QNTS", verification_uri, expires_in }
2. Device displays: "Go to example.com/activate, enter GFKJ-QNTS"
3. Device polls: POST /token (grant_type=urn:ietf:params:oauth:grant-type:device_code)
→ responds with "authorization_pending" until user approves
4. User approves on phone
5. Device poll returns tokensUse this for CLI tools (like the GitHub CLI or Azure CLI), smart TVs, and IoT devices.
Deprecated — Implicit Flow
The Implicit flow returned tokens directly in the URL fragment (#access_token=...). Problems:
- Tokens appear in browser history, server logs, referrer headers
- No refresh tokens
- No way to verify the token wasn't intercepted
- PKCE solves all of this
Do not use Implicit flow. All major auth servers still support it for legacy reasons but it should be disabled for new clients.
Deprecated — Resource Owner Password Credentials
The app collects the user's username and password directly and exchanges them for tokens.
POST /token
grant_type=password
&username=user@example.com
&password=hunter2
&client_id=...Problems:
- The app sees the user's password — defeats the purpose of OAuth
- No MFA support
- No redirects — no way for the auth server to step up authentication
- RFC 6749 itself marks it as legacy
The only legitimate use case (first-party apps on platforms where redirects are impossible) now has better alternatives. Do not use it.
Scopes — What They Mean
Scopes define what the access token is allowed to do. They're space-separated strings agreed on between the auth server and the resource server.
openid — OIDC: include an ID token
profile — OIDC: name, picture, etc.
email — OIDC: email address
api:read — custom: read-only access to the API
api:write — custom: write access
reports:export — custom: specific featureScope design tip: be specific. api:read and api:write are better than one api scope. invoices:read is better than api:read if you want fine-grained control.
OpenID Connect (OIDC)
OAuth 2.0 is authorization only — it doesn't define who the user is. OpenID Connect is a thin identity layer on top of OAuth 2.0 that adds:
- The
openidscope - The ID token (JWT about the user)
- The
/userinfoendpoint - Standard claims (sub, name, email, picture)
- Discovery document (
/.well-known/openid-configuration)
When you want both "who is this user" and "what can they do", use OAuth 2.0 + OIDC together.
Quick Reference
| Scenario | Flow | |----------|------| | SPA (React, Angular, Vue) | Authorization Code + PKCE | | Mobile app | Authorization Code + PKCE | | Server-side web app | Authorization Code (+ PKCE is fine too) | | Microservice calling API | Client Credentials | | CLI tool or smart device | Device Authorization | | Legacy only | Implicit, ROPC |
Enjoyed this article?
Explore the Security & Compliance learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.