Web Security & Ethical Hacking · Lesson 10 of 23

OAuth 2.0 Flows — Which One to Use and When

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.

TYPESCRIPT
// 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:

TYPESCRIPT
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:

C#
// 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:read

Returns 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.

C#
// ASP.NET Core — calling a downstream API with client credentials
services.AddHttpClient("ReportService")
    .AddClientCredentialsTokenHandler(); // from Duende.AccessTokenManagement

// The library handles token caching and renewal automatically

Flow 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 tokens

Use 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 feature

Scope 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 openid scope
  • The ID token (JWT about the user)
  • The /userinfo endpoint
  • 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 |