πŸ“š Learning Hub
Β· 7 min read

How OAuth Actually Works β€” Tokens, Flows, and Why It's Confusing


Every time you click β€œSign in with Google” or β€œContinue with GitHub,” OAuth is doing the work behind the scenes. It’s one of the most widely used protocols on the web β€” and one of the most misunderstood. Developers use it daily without fully grasping what’s happening between the redirects, tokens, and callbacks.

This post breaks down how OAuth 2.0 actually works, step by step, with no hand-waving.

The Problem OAuth Solves

Imagine you’re building an app that needs to read a user’s GitHub repositories. Before OAuth, the common approach was grim: ask the user for their GitHub username and password, then use those credentials to call GitHub’s API on their behalf.

This is terrible for obvious reasons:

  • The user has to trust your app with their actual password.
  • Your app gets full access to their account, not just repos.
  • If your app gets breached, the user’s GitHub password leaks.
  • The user can’t revoke your app’s access without changing their password (which breaks every other app they shared it with).

OAuth solves this with delegated authorization. The user never gives your app their password. Instead, they grant your app limited, revocable permission directly through the service (GitHub, Google, etc.). Your app receives a token β€” a scoped key β€” that lets it do only what the user approved. If you want a broader introduction to the concept, see What is OAuth?.

Authentication vs. Authorization

This distinction trips up almost everyone.

  • Authentication answers: β€œWho are you?” (proving identity)
  • Authorization answers: β€œWhat are you allowed to do?” (granting permissions)

OAuth 2.0 is an authorization framework. It was designed to let apps access resources on behalf of a user β€” not to verify who the user is. The β€œSign in with Google” button you see everywhere actually uses OpenID Connect (OIDC), which is a thin identity layer built on top of OAuth 2.0. More on that distinction later.

The Authorization Code Flow β€” Step by Step

The Authorization Code flow is the most common and most secure OAuth 2.0 flow. It’s what happens when you click β€œSign in with GitHub” on a web app. Here’s the full sequence:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚          β”‚  1. Click "Login with GitHub"     β”‚              β”‚                        β”‚                β”‚
β”‚   User   β”‚ ──────────────────────────────▢   β”‚   Your App   β”‚                        β”‚  GitHub (Auth  β”‚
β”‚ (Browser)β”‚                                   β”‚  (Client)    β”‚                        β”‚    Server)     β”‚
β”‚          β”‚  2. Redirect to GitHub login      β”‚              β”‚                        β”‚                β”‚
β”‚          β”‚ ◀──────────────────────────────   β”‚              β”‚                        β”‚                β”‚
β”‚          β”‚                                   β”‚              β”‚                        β”‚                β”‚
β”‚          β”‚  3. User logs in + approves       β”‚              β”‚                        β”‚                β”‚
β”‚          β”‚ ──────────────────────────────────────────────────────────────────────▢   β”‚                β”‚
β”‚          β”‚                                   β”‚              β”‚                        β”‚                β”‚
β”‚          β”‚  4. Redirect back with auth code  β”‚              β”‚                        β”‚                β”‚
β”‚          β”‚ ◀────────────────────────────────────────────────────────────────────── β”‚                β”‚
β”‚          β”‚                                   β”‚              β”‚                        β”‚                β”‚
β”‚          β”‚  5. Browser follows redirect      β”‚              β”‚                        β”‚                β”‚
β”‚          β”‚ ──────────────────────────────▢   β”‚              β”‚                        β”‚                β”‚
β”‚          β”‚                                   β”‚  6. Exchange auth code + secret       β”‚                β”‚
β”‚          β”‚                                   β”‚     for access token (server-side)    β”‚                β”‚
β”‚          β”‚                                   β”‚ ─────────────────────────────────▢   β”‚                β”‚
β”‚          β”‚                                   β”‚              β”‚                        β”‚                β”‚
β”‚          β”‚                                   β”‚  7. Access token (+ refresh token)    β”‚                β”‚
β”‚          β”‚                                   β”‚ ◀─────────────────────────────────   β”‚                β”‚
β”‚          β”‚                                   β”‚              β”‚                        β”‚                β”‚
β”‚          β”‚  8. User is logged in             β”‚  9. Use access token to call API      β”‚                β”‚
β”‚          β”‚ ◀──────────────────────────────   β”‚ ─────────────────────────────────▢   β”‚                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Let’s walk through each step:

  1. User clicks β€œLogin with GitHub” in your app.
  2. Your app redirects the browser to GitHub’s authorization endpoint, including your app’s client_id, a redirect_uri, the requested scope (e.g., read:user), and a random state parameter (CSRF protection).
  3. The user authenticates with GitHub (if not already logged in) and sees a consent screen: β€œThis app wants to access your profile and repos. Allow?”
  4. GitHub redirects back to your app’s redirect_uri with a short-lived authorization code in the URL query string.
  5. The browser follows the redirect to your server.
  6. Your server exchanges the authorization code for an access token by making a back-channel POST to GitHub’s token endpoint, sending the code, your client_id, and your client_secret.
  7. GitHub responds with an access token (and optionally a refresh token).
  8. Your app creates a session for the user.
  9. Your server uses the access token to call GitHub’s API on behalf of the user.

The critical security property: the access token never touches the browser. The authorization code is the only thing exposed in the URL, and it’s single-use and short-lived. The actual token exchange happens server-to-server.

Access Tokens vs. Refresh Tokens

These two token types serve very different purposes.

Access tokens are the keys your app uses to call APIs. They’re short-lived β€” typically 15 minutes to an hour. They’re included in API requests as a Bearer token in the Authorization header. Many providers issue access tokens as JWTs, which means the resource server can validate them without calling the auth server. For a deeper look at how that validation works, see How JWT Actually Works.

Refresh tokens are long-lived credentials (days, weeks, or even indefinite) that your app stores securely on the server. When an access token expires, your app sends the refresh token to the auth server’s token endpoint and gets a fresh access token β€” no user interaction required.

Why not just make access tokens long-lived? Because if an access token leaks, the damage is limited to its short lifespan. A refresh token is stored server-side and never sent to resource APIs, so its exposure surface is much smaller.

PKCE β€” Why It Matters for SPAs and Mobile Apps

The classic Authorization Code flow relies on a client_secret to exchange the auth code for a token. That works for server-side apps, but single-page apps and mobile apps can’t keep secrets β€” their code runs on the user’s device.

PKCE (Proof Key for Code Exchange, pronounced β€œpixy”) solves this. Before redirecting to the auth server, your app generates a random code_verifier and derives a code_challenge from it (a SHA-256 hash). The challenge is sent with the initial authorization request. When exchanging the auth code for a token, your app sends the original code_verifier. The auth server hashes it and checks that it matches the challenge it received earlier.

This prevents authorization code interception attacks β€” even if an attacker grabs the auth code from the redirect URL, they can’t exchange it without the code_verifier that only your app knows. PKCE is now recommended for all OAuth clients, not just public ones. It’s part of the OAuth 2.1 draft spec.

Common OAuth Providers

Most developers interact with OAuth through one of these providers:

  • Google β€” supports OAuth 2.0 + OIDC, widely used for β€œSign in with Google,” provides ID tokens with user profile info.
  • GitHub β€” OAuth 2.0 for accessing repos, gists, user data. Commonly used in developer tools and CI/CD pipelines.
  • Auth0 / Okta β€” identity-as-a-service platforms that handle OAuth/OIDC for you, so you don’t have to run your own auth server.
  • Microsoft Entra ID (Azure AD) β€” enterprise OAuth/OIDC for Microsoft 365 and Azure resources.
  • Apple β€” β€œSign in with Apple,” required for iOS apps that offer third-party login.

Each provider has quirks in their implementation, but the core OAuth 2.0 flow is the same. The differences are usually in scopes, token formats, and consent screen behavior.

OAuth vs. OpenID Connect (OIDC)

This is where the confusion peaks.

OAuth 2.0 is about authorization β€” granting an app access to resources. It doesn’t define a standard way to get user identity information.

OpenID Connect (OIDC) is a layer on top of OAuth 2.0 that adds authentication. It introduces:

  • An ID token (a JWT containing user identity claims like email, name, and subject ID).
  • A standardized /userinfo endpoint.
  • A openid scope that signals you want identity info, not just resource access.

When you β€œlog in with Google,” you’re using OIDC. When your app reads a user’s Google Drive files, you’re using OAuth. In practice, most β€œOAuth login” implementations are actually OIDC β€” they just don’t call it that.

Common Mistakes Developers Make

Using the Implicit flow. The Implicit flow returns tokens directly in the URL fragment. It was designed for SPAs before PKCE existed. It’s now deprecated β€” use Authorization Code + PKCE instead.

Not validating the state parameter. The state parameter prevents CSRF attacks. If you don’t generate a random value, store it in the session, and verify it on callback, an attacker can trick a user into linking the attacker’s account.

Storing tokens in localStorage. Access tokens in localStorage are accessible to any JavaScript on the page, including XSS payloads. Use httpOnly cookies or keep tokens server-side.

Treating access tokens as proof of identity. An access token tells you what the bearer can do, not who they are. For identity, you need an OIDC ID token β€” and you need to validate it properly.

Ignoring token expiration. Don’t assume tokens last forever. Implement refresh token rotation and handle expired tokens gracefully.

Not using HTTPS. OAuth tokens are bearer tokens β€” anyone who intercepts them can use them. All OAuth communication must happen over TLS. This isn’t optional.

Wrapping Up

OAuth 2.0 is a delegation protocol: it lets users grant limited access to their resources without sharing credentials. The Authorization Code flow (with PKCE) is the standard for both server and client apps. Access tokens are short-lived API keys; refresh tokens keep sessions alive without re-prompting the user. And if you need to know who the user is β€” not just what they’ve authorized β€” you want OIDC on top of OAuth.

The protocol is confusing because it’s a framework, not a rigid spec. Every provider implements it slightly differently, and the terminology overloads words that already mean other things. But the core mechanics are straightforward once you see the full picture.