Skip to main content

🔑 OAuth and OpenID Connect

Every "Sign in with Google" button rests on two standards working together: OAuth 2.0 for authorization — granting limited access to your data — and OpenID Connect for authentication — proving who you are. This lesson untangles the two, walks the grant types you'll actually use, and shows why PKCE is now the default for browser and mobile apps.

🎯 Learning Objectives

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

  • Name the four OAuth 2.0 roles and explain what each does
  • Choose the right grant type for web, SPA, mobile, and server-to-server scenarios
  • Explain how PKCE protects public clients from code-interception attacks
  • Distinguish access, refresh, and ID tokens and where each belongs
  • Describe how OpenID Connect layers authentication on top of OAuth, and validate an ID token

Estimated Time: 50–70 minutes  •  Difficulty: Intermediate–Advanced

Hands-on: Trace the Authorization Code + PKCE flow and build the client-side verifier/challenge step.

In This Lesson

OAuth 2.0: The Valet Key

OAuth 2.0 is an authorization framework. It lets a third-party application obtain limited access to a user's account on another service — without the user ever handing over their password to that third party.

💡 A useful analogy: OAuth is a valet key for your car. A regular key opens everything; a valet key only starts the engine and unlocks the doors — not the trunk or glove box. OAuth gives an app a "valet key" to specific parts of your data while your real "master key" (your password) never leaves the provider.

⚠️ The distinction that trips everyone up

OAuth 2.0 is about authorization ("what may this app do on my behalf?"), not authentication ("who is this user?"). Using raw OAuth to "log people in" is a well-known anti-pattern. When you need to know who the user is, you want OpenID Connect — which sits on top of OAuth and is covered later in this lesson.

The Four Roles

Every OAuth flow is a conversation between four parties. Keep these straight and every diagram below becomes readable:

RoleWho it isExample
Resource OwnerThe user who owns the dataYou, the Google Drive account holder
ClientThe app requesting accessA calendar app wanting your events
Authorization ServerAuthenticates the user and issues tokensGoogle's account login & consent screens
Resource ServerHosts the protected data / APIThe Google Drive API
flowchart TD A[Resource Owner / User] -->|Authorizes access| B[Client Application] B -->|Authorization request| C[Authorization Server] C -->|Authorization grant| B B -->|Exchange grant for token| C C -->|Access token| B B -->|Access token| D[Resource Server] D -->|Protected resources| B

Grant Types (Flows)

OAuth defines several grant types — recipes for obtaining a token — each suited to a different kind of client. Two are current best practice; two are legacy you should recognize but avoid.

Grant typeUse it forStatus
Authorization Code + PKCEWeb apps, SPAs, mobile apps — the default today✅ Recommended
Client CredentialsServer-to-server, no user involved✅ Recommended
Implicit(Formerly SPAs)❌ Deprecated — use Code + PKCE
Resource Owner Password(Formerly trusted first-party apps)❌ Discouraged — user hands password to the client

Authorization Code flow (the classic)

The most secure flow for apps with a backend that can keep a secret. The user authenticates at the authorization server, which hands back a short-lived code; the client's backend then exchanges that code (plus its secret) for tokens.

sequenceDiagram participant U as User participant C as Client participant A as Auth Server participant R as Resource Server U->>C: Use application C->>U: Redirect to Auth Server U->>A: Authenticate & consent A->>U: Redirect back with authorization code U->>C: Authorization code C->>A: Exchange code (+ client secret) for tokens A->>C: Access token & refresh token C->>R: Request with access token R->>C: Protected resource

Think of it like getting a pass at a secure building: reception sends you to security, security verifies your ID and gives you a request slip (the code), you bring the slip back, reception phones security to confirm it, and only then is a building pass (access token) issued.

Client Credentials flow (no user)

When one backend service calls another on its own behalf — a nightly job hitting an internal API — there's no user to redirect. The client simply authenticates with its ID and secret:

const axios = require('axios');

async function getServiceToken() {
  const { data } = await axios.post(
    'https://auth.example.com/oauth/token',
    new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
      scope: 'reports:read'
    }),
    { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
  );
  return data.access_token;
}

⚠️ Why Implicit and Password grants fell out of favor

The Implicit flow returned the access token directly in the URL fragment, where it could leak through browser history, referrers, and logs. The Resource Owner Password grant requires the user to type their password into the client app — defeating OAuth's core promise. Modern guidance (OAuth 2.1) drops both in favor of Authorization Code + PKCE.

Authorization Code + PKCE

PKCE (Proof Key for Code Exchange, pronounced "pixie") extends the Authorization Code flow so it's safe for public clients — SPAs and mobile apps that can't keep a client secret. It closes the window where an attacker who intercepts the authorization code could redeem it.

The trick: before starting, the client invents a random secret (the code verifier) and sends only a hash of it (the code challenge) with the authorization request. When redeeming the code, it must present the original verifier. An attacker who steals the code can't use it without the verifier they never saw.

sequenceDiagram participant U as User participant C as Client (SPA/mobile) participant A as Auth Server C->>C: Generate code verifier + challenge (SHA-256) U->>C: Start login C->>A: Redirect with code_challenge U->>A: Authenticate & consent A->>U: Redirect with authorization code U->>C: Authorization code C->>A: Send code + code_verifier A->>A: Hash verifier, compare to stored challenge A->>C: Access token (+ refresh token)

Here's the client-side verifier/challenge generation in a browser SPA, using the Web Crypto API:

// Generate a high-entropy verifier and its SHA-256 challenge
function base64UrlEncode(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function generateCodeVerifier() {
  const random = new Uint8Array(32);
  crypto.getRandomValues(random);
  return base64UrlEncode(random);
}

async function generateCodeChallenge(verifier) {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(digest));
}

async function startLogin() {
  const verifier = generateCodeVerifier();
  const challenge = await generateCodeChallenge(verifier);
  sessionStorage.setItem('pkce_verifier', verifier); // needed at the exchange step

  const url = new URL('https://auth.example.com/authorize');
  url.searchParams.set('client_id', 'YOUR_CLIENT_ID');
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('redirect_uri', 'https://app.example.com/callback');
  url.searchParams.set('scope', 'openid profile email');
  url.searchParams.set('code_challenge', challenge);
  url.searchParams.set('code_challenge_method', 'S256');
  url.searchParams.set('state', crypto.randomUUID()); // CSRF protection
  window.location.href = url.toString();
}

Then, on the callback page, exchange the code together with the stored verifier:

async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const verifier = sessionStorage.getItem('pkce_verifier');

  const res = await fetch('https://auth.example.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: 'YOUR_CLIENT_ID',
      code,
      code_verifier: verifier,
      redirect_uri: 'https://app.example.com/callback'
    })
  });
  const tokens = await res.json(); // { access_token, refresh_token, id_token }
  sessionStorage.removeItem('pkce_verifier');
  return tokens;
}

Access, Refresh & ID Tokens

OAuth/OIDC hands out up to three kinds of token, and mixing them up causes real bugs. Here's the mental model:

TokenAnswersSent toLifetime
Access token"May I do this?"The resource server (API)Short (minutes)
Refresh token"Give me a new access token"The authorization server onlyLong (days–months)
ID token (OIDC)"Who is this user?"The client only — never an APIShort

📖 The rule that prevents the classic mistake

Send the access token to APIs. Keep the ID token on the client to learn the user's identity. Sending an ID token to a resource server, or an access token you try to "read" for identity, is a common and dangerous confusion.

An ID token is always a JWT. Its payload carries identity claims:

{
  "iss": "https://auth.example.com",   // issuer
  "sub": "user-123",                    // stable user identifier
  "aud": "YOUR_CLIENT_ID",              // audience (this client)
  "exp": 1719999999,                    // expiration
  "iat": 1719996399,                    // issued at
  "nonce": "n-0S6_WzA2Mj",              // replay protection
  "name": "John Doe",
  "email": "john.doe@example.com",
  "email_verified": true
}

Using a refresh token to get a fresh access token is a simple POST to the token endpoint:

async function refresh(refreshToken) {
  const res = await fetch('https://auth.example.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: 'YOUR_CLIENT_ID'
    })
  });
  const data = await res.json();
  // Some servers rotate the refresh token — use the new one if present
  return { accessToken: data.access_token, refreshToken: data.refresh_token || refreshToken };
}

Scopes

Scopes are how OAuth limits access. The client asks for exactly the permissions it needs, the user sees them on the consent screen, and the resulting token is stamped with only what was approved.

flowchart LR A[App requests scopes] --> B{Consent screen} B --> C[User approves or denies] C --> D[Token issued with approved scopes] D --> E[Resource server enforces scopes]

Think of scopes like smartphone app permissions: an app can request the camera, contacts, and location, but you approve each, and it can touch only what you allowed. Common scopes include openid, profile, email, and offline_access (which requests a refresh token); providers also define their own, like https://www.googleapis.com/auth/drive.readonly.

On the resource server, you enforce scopes per route. This middleware rejects requests whose token lacks the required scope:

function requireScopes(...required) {
  return (req, res, next) => {
    const header = req.headers.authorization || '';
    const token = header.startsWith('Bearer ') ? header.slice(7) : null;
    if (!token) return res.status(401).json({ error: 'Access token required' });

    try {
      const decoded = jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] });
      const granted = (decoded.scope || '').split(' ');
      const ok = required.every(s => granted.includes(s));
      if (!ok) return res.status(403).json({ error: 'insufficient_scope', required });
      req.user = decoded;
      next();
    } catch {
      return res.status(401).json({ error: 'Invalid token' });
    }
  };
}

app.post('/api/calendar/events', requireScopes('calendar.write'), createEvent);

✅ Principle of least privilege

Request the fewest scopes that make your feature work. Fewer scopes mean a smaller blast radius if a token leaks, a friendlier consent screen, and more user trust.

OpenID Connect

OpenID Connect (OIDC) is a thin identity layer on top of OAuth 2.0. Where OAuth answers "what can this app do?", OIDC answers "who is this user?" — turning the authorization framework into a proper authentication protocol.

flowchart TD A[OpenID Connect] -->|extends| B[OAuth 2.0] A -->|adds| C[ID Token] A -->|adds| D[UserInfo endpoint] A -->|adds| E[Standard claims] A -->|adds| F[Discovery document]

OIDC adds a handful of well-defined pieces on top of the flows you already know:

  • ID Token — a JWT proving the user's identity to the client.
  • UserInfo endpoint — an API returning profile claims for the authenticated user.
  • Standard claimssub, name, email, and friends, so every provider looks the same.
  • Discovery — a /.well-known/openid-configuration document that tells clients where every endpoint and key lives.

Extending the valet analogy: OAuth is the valet key; OIDC is checking the driver's license before issuing the key — it verifies identity, not just access.

Using an identity provider in practice

Rarely do you build this by hand. Libraries like Auth0's SDKs wrap OIDC + PKCE. In React:

// main.jsx
import { Auth0Provider } from '@auth0/auth0-react';

createRoot(document.getElementById('root')).render(
  <Auth0Provider
    domain="your-tenant.auth0.com"
    clientId="YOUR_CLIENT_ID"
    authorizationParams={{
      redirect_uri: window.location.origin,
      scope: 'openid profile email'
    }}
  >
    <App />
  </Auth0Provider>
);

// A component
import { useAuth0 } from '@auth0/auth0-react';

function Profile() {
  const { user, isAuthenticated, loginWithRedirect, logout } = useAuth0();
  if (!isAuthenticated) return <button onClick={() => loginWithRedirect()}>Log in</button>;
  return (
    <div>
      <p>{user.name} — {user.email}</p>
      <button onClick={() => logout()}>Log out</button>
    </div>
  );
}

Validating an ID token

Whenever your own backend receives an ID token, validate it fully before trusting a single claim. Use the provider's published keys (JWKS) to check the signature, then verify the standard claims:

const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

const client = jwksClient({ jwksUri: 'https://auth.example.com/.well-known/jwks.json' });

function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}

function validateIdToken(idToken) {
  return new Promise((resolve, reject) => {
    jwt.verify(idToken, getKey, {
      algorithms: ['RS256'],
      audience: 'YOUR_CLIENT_ID',                 // aud must be us
      issuer: 'https://auth.example.com'          // iss must be the expected provider
    }, (err, decoded) => (err ? reject(err) : resolve(decoded)));
    // jwt.verify already checks exp and nbf; also compare nonce to the one you sent.
  });
}

⚠️ Non-negotiable ID-token checks

Verify the signature, the issuer (iss), the audience (aud = your client ID), the expiry (exp), and — if you sent one — the nonce. Skipping any of these can let a token from another app, or a replayed old token, slip through.

Hands-on Exercise

🏋️ Trace and build the PKCE handshake

Objective: Understand PKCE deeply enough to explain why a stolen authorization code is useless to an attacker.

Part A — Trace it

  1. On paper, list every message in the Authorization Code + PKCE sequence diagram above.
  2. At the step where the code travels back through the browser, write one sentence: if an attacker copies the code here, what do they still lack?

Part B — Build the crypto step

  1. In a browser console (or a small HTML page), implement generateCodeVerifier() and generateCodeChallenge() from the PKCE section.
  2. Log both values. Verify the challenge changes completely if you alter one character of the verifier.
  3. Confirm the challenge is a valid base64url string (no +, /, or =).
💡 Hint

The attacker in Part A has the code but not the code verifier — which never left the legitimate client. Without it, the token exchange fails because the server hashes the presented verifier and compares it to the stored challenge.

✅ Sample answer (Part A)

An intercepted authorization code cannot be redeemed because the token endpoint requires the matching code_verifier. The attacker only ever saw the code_challenge (a SHA-256 hash), and a hash can't be reversed into the verifier. So the code is worthless without the secret the real client kept in sessionStorage.

Security Best Practices

✅ Do❌ Don't
Use Authorization Code + PKCE for web, SPA, and mobile clientsUse the Implicit or Password grants in new apps
Send a random state and verify it on the callback (CSRF)Ignore state, leaving the flow open to CSRF
Register exact redirect URIs and match them strictlyAllow wildcard or loosely-matched redirect URIs
Request the minimum scopes neededAsk for broad scopes "just in case"
Fully validate ID tokens (sig, iss, aud, exp, nonce)Trust an ID token's claims without verification
Keep access tokens short and refresh over HTTPSPut tokens in URLs or send them over plain HTTP

The state parameter deserves special mention: it's a random value you send with the authorization request and verify unchanged on return. It ties the callback to the browser that started the flow, defeating CSRF against your redirect endpoint. Generate it with crypto.randomUUID(), stash it in sessionStorage, and reject the callback if it doesn't match.

🎯 Quick Quiz

Question 1: What is the core difference between OAuth 2.0 and OpenID Connect?

Question 2: Why does PKCE make a stolen authorization code useless to an attacker?

Question 3: Which token should you send to a resource server (an API)?

Summary & Quiz

🎉 Key Takeaways

  • OAuth 2.0 is authorization (delegated access); OpenID Connect adds authentication (identity) on top.
  • Four roles run every flow: resource owner, client, authorization server, resource server.
  • Authorization Code + PKCE is today's default for web, SPA, and mobile; Implicit and Password grants are deprecated.
  • PKCE makes an intercepted code useless without the secret verifier the client kept.
  • Send access tokens to APIs, keep ID tokens on the client, and always fully validate ID tokens.

📚 Further Reading

🚀 What's Next?

You've secured the front door with authentication and delegated identity. Next, API Security Best Practices pulls it all together — rate limiting, input validation, CORS, security headers, and defending against the OWASP Top 10.

🎉 Great work!

OAuth and OIDC are the standards behind nearly every "Sign in with…" button. You now know what happens behind the redirect.