Skip to main content

🔐 OAuth 2.0 Flow Explained

Every time you click "Sign in with Google," a carefully choreographed handshake runs between your browser, the app, and Google — and nobody ever sees your Google password except Google. That dance is OAuth 2.0. In this lesson you'll learn exactly who does what, in what order, and why each step exists.

Week 9 · Thursday: OAuth and Social Login · Lecture 1

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what OAuth 2.0 is — and distinguish delegated authorization from authentication
  • Name the four OAuth roles and map each to a real player in a "Login with X" flow
  • Walk through the Authorization Code flow step by step, from redirect to token exchange
  • Describe how PKCE protects public clients that can't keep a secret
  • Explain how the state parameter prevents CSRF and why the client secret must stay server-side
  • Choose the right grant type for web apps, SPAs, mobile apps, and machine-to-machine calls

Estimated Time: 70 minutes

Practice: Trace an authorization request, build the redirect URL, and reason about a state-mismatch attack.

In This Lesson

What Problem Does OAuth Solve?

Imagine a photo-printing site wants to grab the photos from your Google account. The naive approach would be to ask for your Google email and password and log in as you. That's a disaster: the site now knows your password, can read your email, can change your account, and there's no way to revoke just the printing site without changing your password everywhere.

OAuth 2.0 is the industry-standard protocol that fixes this. It lets you grant an app limited, revocable access to specific parts of your account — without ever handing over your credentials.

🅿️ Real-world analogy: the valet key

Many cars ship with a valet key. It starts the engine and opens the door, but it won't open the trunk or the glovebox. You hand it to a parking attendant so they can park the car — but they can't get into your private compartments, and you keep your real key. An OAuth access token is a valet key for your data: scoped to specific permissions, time-limited, and revocable at any moment without touching your password.

When you click "Sign in with Google," three things are true that make OAuth worth the complexity:

  • You never type your Google password into the third-party site — you type it into Google's page.
  • You grant a specific scope (say, "read your profile and email") — not blanket access.
  • You can visit your Google account settings later and revoke that one app, leaving everything else intact.

The Four Roles

OAuth defines four roles. Everything in the protocol is a message passing between them. Learn these names — the whole spec reads clearly once you do.

RoleWhat it isIn "Login with Google"
Resource OwnerThe human who owns the data and can grant accessYou
ClientThe application that wants access on your behalfThe website you're signing in to
Authorization ServerAuthenticates you and issues tokensGoogle's OAuth server (accounts.google.com)
Resource ServerHolds the protected data; accepts access tokensGoogle's API (e.g. the userinfo endpoint)
graph LR RO["Resource Owner
(you)"] -->|grants permission| C["Client
(the app)"] C -->|asks for a token| AS["Authorization Server
(Google login)"] AS -->|issues access token| C C -->|calls API with token| RS["Resource Server
(Google API)"] RS -->|returns your data| C

A few more terms you'll meet constantly:

TermMeaning
access_tokenShort-lived credential the client sends on every API call
refresh_tokenLonger-lived credential used to obtain a fresh access token without re-prompting the user
scopeThe specific permissions being requested, e.g. profile email
client_idPublic identifier for the app, issued when you register it with the provider
client_secretPrivate password for the app — server-side only, never in the browser

Authorization vs. Authentication

This is the single most important conceptual point in the lesson, and it trips up experienced developers.

  • Authentication answers "who are you?" — proving identity.
  • Authorization answers "what are you allowed to do?" — granting access.

Plain OAuth 2.0 is a delegated authorization protocol. Its job is to give a client a token that grants access to resources. It was not originally designed to tell the client "who the user is."

⚠️ OpenID Connect adds the identity layer

Because everyone wanted to use OAuth for login, the community built OpenID Connect (OIDC) — a thin layer on top of OAuth 2.0. OIDC adds a signed id_token (a JWT) that securely tells the client who the user is. So "Sign in with Google" is really OIDC on top of OAuth: OAuth authorizes access to the profile, and the id_token authenticates the identity. When you see people say "log in with OAuth," they almost always mean OIDC.

The Authorization Code Flow

The Authorization Code flow is the workhorse of OAuth. It's the flow you'll use for any app with a server backend, and (with PKCE) for SPAs and mobile apps too. The key insight: sensitive token exchange happens server-to-server, out of reach of the browser.

Here is the complete choreography. Read the diagram top to bottom — this is the sequence you'll implement.

sequenceDiagram participant U as Resource Owner participant B as Browser participant C as Client (your server) participant A as Authorization Server participant R as Resource Server U->>B: Clicks "Login with Provider" B->>C: GET /auth/login C->>B: Redirect to /authorize (client_id, redirect_uri, scope, state) B->>A: GET /authorize with those params A->>U: Show login and consent screen U->>A: Authenticate and approve scopes A->>B: Redirect to redirect_uri with code and state B->>C: GET /auth/callback with code and state C->>A: POST /token (code, client_id, client_secret, redirect_uri) A->>C: access_token, refresh_token, expires_in C->>R: GET /userinfo with Bearer access_token R->>C: Protected user data C->>B: Set session, redirect to dashboard

Step 1 — Send the user to the authorization server

The client builds a URL to the provider's /authorize endpoint and redirects the browser there. Notice it generates a random state and stores it in the session first.

const crypto = require('crypto');

// Step 1: Redirect the user to the provider's consent screen
app.get('/auth/login', (req, res) => {
  // A random, unguessable value tied to THIS browser session (CSRF defense)
  const state = crypto.randomBytes(16).toString('hex');
  req.session.oauthState = state;

  const authUrl = new URL('https://authorization-server.com/oauth/authorize');
  authUrl.searchParams.set('response_type', 'code');          // ask for an auth CODE
  authUrl.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', process.env.OAUTH_REDIRECT_URI);
  authUrl.searchParams.set('scope', 'profile email');         // least privilege
  authUrl.searchParams.set('state', state);

  res.redirect(authUrl.toString());
});

Why it matters: the browser is only ever redirected to the provider. Your server never sees the user's provider password — that's the whole point.

Step 2 — User authenticates and consents

On the provider's own domain, the user logs in (if not already) and sees a consent screen: "This app wants to view your profile and email. Allow?" The provider — not you — handles passwords, 2FA, and CAPTCHAs.

Step 3 — The provider redirects back with a code

On approval, the provider redirects the browser to your registered redirect_uri with two query parameters: a short-lived code (usually valid ~10 minutes, single use) and the original state.

Step 4 — Exchange the code for tokens (server-side!)

Your server verifies state, then makes a back-channel POST to the token endpoint, including the client_secret. This request never touches the browser.

// Step 3 & 4: Handle the callback and swap the code for tokens
app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;

  // Verify state matches what we stored — blocks CSRF (see next section)
  if (!state || state !== req.session.oauthState) {
    return res.status(403).send('Invalid state parameter');
  }
  delete req.session.oauthState; // one-time use

  try {
    // Back-channel, server-to-server token exchange
    const tokenRes = await fetch('https://authorization-server.com/oauth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: process.env.OAUTH_REDIRECT_URI,     // must match step 1 exactly
        client_id: process.env.OAUTH_CLIENT_ID,
        client_secret: process.env.OAUTH_CLIENT_SECRET    // SECRET — server only!
      })
    });
    const { access_token, refresh_token, expires_in } = await tokenRes.json();

    // Step 5: use the access token to read the user's profile
    const infoRes = await fetch('https://api.example.com/userinfo', {
      headers: { Authorization: `Bearer ${access_token}` }
    });
    const profile = await infoRes.json();

    // Create or update the user, then start a session
    const user = await findOrCreateUser(profile);
    req.session.userId = user.id;
    res.redirect('/dashboard');
  } catch (err) {
    console.error('OAuth error:', err);
    res.status(500).send('Authentication failed');
  }
});

✅ Why a code instead of a token straight away?

The authorization code travels through the browser (a redirect URL), which is a relatively hostile place — it can end up in history, logs, or a referrer header. But a code is useless on its own: redeeming it requires the client_secret, which lives only on your server. The valuable access_token is delivered over a direct, encrypted, server-to-server channel. That two-step design is exactly why the Authorization Code flow is the secure default.

The state Parameter & CSRF

The state parameter is a random value your app generates, stores in the session, and sends to the authorization server. The server echoes it back on the redirect. Your callback then checks that the returned state equals the stored one.

Without it, you're open to a login CSRF attack: an attacker completes step 1 with their own account, captures the resulting code, and tricks a victim's browser into hitting your callback with that code — silently logging the victim into the attacker's account. Because the attacker never generated the victim's session state, the mismatch check rejects the forged callback.

The state parameter round-trips through the provider and is verified on return Your server generate state store in session Auth server echoes state Callback returned == stored? ✅ or reject
The state value must survive the round-trip and match on return. A mismatch means the callback wasn't started by this browser — reject it.

💡 Make state unguessable and single-use

Use a cryptographically random value (crypto.randomBytes), tie it to the server-side session, and delete it after one use. Pairing it with a SameSite=Lax session cookie hardens the flow further.

PKCE for Public Clients

The Authorization Code flow relies on the client_secret to prove the token request is genuine. But a public client — a single-page app or a mobile app — runs entirely on the user's device. Anything you ship there can be extracted, so it cannot keep a secret. That's where PKCE comes in.

PKCE ("Proof Key for Code Exchange," pronounced "pixie") replaces the static secret with a fresh, per-request secret the client makes up on the spot:

  1. The client generates a random code verifier and keeps it locally.
  2. It hashes the verifier (SHA-256) to produce a code challenge, which it sends in the authorization request.
  3. At token-exchange time it sends the original verifier. The server hashes it and checks it matches the challenge it saw earlier.

An attacker who steals the authorization code from the redirect still can't redeem it — they never had the verifier, and they can't reverse the hash.

sequenceDiagram participant App as Public Client (SPA) participant A as Authorization Server App->>App: Generate random verifier, then challenge = SHA256 of verifier App->>A: /authorize with code_challenge + method S256 A->>App: Redirect with authorization code App->>A: /token with code + code_verifier A->>A: Recompute SHA256 of verifier and compare to stored challenge A->>App: access_token only if it matches
// Generate a PKCE code verifier and challenge in the browser (Web Crypto API)
function base64UrlEncode(bytes) {
  return btoa(String.fromCharCode(...new Uint8Array(bytes)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function generateCodeVerifier() {
  const bytes = new Uint8Array(32);          // 256 bits of entropy
  crypto.getRandomValues(bytes);
  return base64UrlEncode(bytes);
}

async function generateCodeChallenge(verifier) {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(digest);           // send this in the authorize request
}

async function startLogin() {
  const verifier = generateCodeVerifier();
  sessionStorage.setItem('pkce_verifier', verifier); // keep locally for the swap
  const challenge = await generateCodeChallenge(verifier);

  const url = new URL('https://authorization-server.com/oauth/authorize');
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', 'YOUR_CLIENT_ID');   // no secret needed
  url.searchParams.set('redirect_uri', location.origin + '/callback');
  url.searchParams.set('scope', 'profile email');
  url.searchParams.set('state', generateCodeVerifier()); // reuse the random helper
  url.searchParams.set('code_challenge', challenge);
  url.searchParams.set('code_challenge_method', 'S256'); // always S256, never "plain"
  location.href = url.toString();
}

✅ PKCE is now recommended for all clients

PKCE started as a fix for mobile and SPA clients, but the OAuth 2.1 draft and current security best-current-practice guidance recommend it for every client — even confidential server apps that already have a secret. It's a cheap, defence-in-depth layer against authorization-code interception. When in doubt, turn it on.

Choosing a Grant Type

OAuth defines several grant types (flows) for different situations. In modern applications your choice is simpler than the full list suggests.

Grant typeUse it forVerdict
Authorization CodeWeb apps with a server backend✅ The default
Authorization Code + PKCESPAs, mobile & native apps✅ Recommended (and for all clients)
Client CredentialsService-to-service, no user involved✅ For backend / machine calls
Device CodeInput-limited devices (smart TVs, CLIs)✅ For that niche
ImplicitOld browser-only apps❌ Deprecated — use Code + PKCE
Resource Owner PasswordTrusted first-party legacy apps❌ Avoid — defeats OAuth's purpose

💡 The Client Credentials flow has no user

When one of your own services calls another's API, there's no human to consent. The Client Credentials flow simply POSTs client_id + client_secret + grant_type=client_credentials to the token endpoint and gets an access token back — no redirect, no browser. Use it for cron jobs, microservice-to-microservice calls, and CI pipelines.

⚠️ Why the Implicit flow was deprecated

The old Implicit flow returned the access token directly in the redirect URL fragment — exposing it to browser history, extensions, and the referrer header, with no code-exchange step to protect it. It has been dropped from OAuth 2.1. If you find it in a tutorial, that tutorial is out of date. Use Authorization Code + PKCE instead.

Practice & Quiz

🏋️ Exercise 1: Build the authorization URL

Goal: Write buildAuthUrl(config, state) that returns the full URL to send a user to a provider's /authorize endpoint for the Authorization Code flow, requesting profile and email scope.

function buildAuthUrl(config, state) {
    // config = { authorizeEndpoint, clientId, redirectUri }
    // TODO: return the full authorization URL string
}
// Expected: contains response_type=code, the client_id, redirect_uri,
//           scope=profile+email, and the state value.
💡 Hint

Use the URL and searchParams APIs. Set response_type to "code", add each parameter with searchParams.set(...), then return url.toString().

✅ Solution
function buildAuthUrl(config, state) {
    const url = new URL(config.authorizeEndpoint);
    url.searchParams.set('response_type', 'code');
    url.searchParams.set('client_id', config.clientId);
    url.searchParams.set('redirect_uri', config.redirectUri);
    url.searchParams.set('scope', 'profile email');
    url.searchParams.set('state', state);
    return url.toString();
}

The URL API handles encoding for you — spaces in the scope become +/%20 automatically, so you never build query strings by hand.

🏋️ Exercise 2: Reject a forged callback

Goal: Write verifyState(returnedState, session) that returns true only when the returned state matches the stored session state and a state was actually stored.

✅ Solution
function verifyState(returnedState, session) {
    // Both must exist AND be equal. A missing stored state means
    // this browser never started the flow — reject it.
    return Boolean(
        session.oauthState &&
        returnedState &&
        returnedState === session.oauthState
    );
}

This one check is what defeats login-CSRF: an attacker's forged callback carries a state your session never generated, so the comparison fails.

🎯 Quick Quiz

Question 1: Which OAuth role issues access tokens?

Question 2: What is the state parameter's main job?

Question 3: Why does a single-page app use PKCE instead of a client secret?

Best Practices & Pitfalls

✅ Do

  • Use the Authorization Code flow, and add PKCE everywhere
  • Generate a random, single-use state and verify it on the callback
  • Exchange the code for tokens server-side, keeping the client_secret off the browser
  • Register exact redirect_uri values and validate them — never allow open redirects
  • Request the minimum scope your app actually needs
  • Serve everything over HTTPS; store refresh tokens encrypted at rest

❌ Don't

  • Never put the client_secret in front-end code, a mobile bundle, or a public repo
  • Don't use the deprecated Implicit flow or the Resource Owner Password grant
  • Don't skip state verification — it's your CSRF defense
  • Don't put tokens in URLs or logs; send access tokens only in the Authorization header
  • Don't treat a plain OAuth access token as proof of identity — use an OIDC id_token for that

⚠️ The confused-deputy trap

A common mistake is verifying "the token is valid" but not "the token was issued for my app." Always check the token's audience/client and issuer when you validate it, or an attacker can present a token minted for a different application.

Summary

🎉 Key Takeaways

  • OAuth 2.0 is delegated authorization — access without sharing passwords; OpenID Connect adds identity
  • The four roles are resource owner, client, authorization server, resource server
  • The Authorization Code flow sends a code through the browser, then swaps it for tokens server-to-server
  • The state parameter, verified on return, prevents CSRF
  • PKCE lets public clients skip the secret; it's now recommended for everyone
  • The client_secret lives only on the server — never in browser or mobile code

📚 Additional Resources

🚀 What's Next?

You now understand the protocol. Next you'll meet the library that makes it painless in Node.js: Passport.js Strategies — the pluggable middleware that wires Google, GitHub, and dozens of other providers into an Express app.

🎉 Great work!

The OAuth dance stops being mysterious once you can name every dancer. On to implementing it.