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:
- User clicks βLogin with GitHubβ in your app.
- Your app redirects the browser to GitHubβs authorization endpoint, including your appβs
client_id, aredirect_uri, the requestedscope(e.g.,read:user), and a randomstateparameter (CSRF protection). - 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?β
- GitHub redirects back to your appβs
redirect_uriwith a short-lived authorization code in the URL query string. - The browser follows the redirect to your server.
- 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 yourclient_secret. - GitHub responds with an access token (and optionally a refresh token).
- Your app creates a session for the user.
- 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
/userinfoendpoint. - A
openidscope 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.