OAuth 2 flows
Full breakdown of OAuth 2 flows, PKCE, OpenID Connect, refresh tokens, scope design, and the security pitfalls that have bitten major providers.
OAuth Is Not Login
The single most common misconception: OAuth 2 is not an authentication protocol. It is an authorization protocol. The distinction matters.
- Authentication: who are you?
- Authorization: what are you allowed to do?
OAuth answers the second. It lets a third-party app get permission to call an API on a user's behalf, without the user giving the app their password. Whether the user is actually who they claim to be is the responsibility of the authorization server (and its login page), not the OAuth protocol.
When you see "Login with Google" buttons, the underlying protocol is OpenID Connect, which is a thin authentication layer built on top of OAuth 2. OIDC adds an ID token (a JWT signed by the provider) that asserts the user's identity.
The Four Roles
OAuth defines four roles.
| Role | Example | Job |
|---|---|---|
| Resource Owner | The user | Owns the data, grants permission |
| Client | Your app | Wants to access the data |
| Authorization Server | accounts.google.com | Issues tokens after user consent |
| Resource Server | googleapis.com/calendar | Holds the data, accepts tokens |
The authorization server and resource server are often the same operator (Google) but they are logically distinct. The client never sees the user's password.
Authorization Code Flow
This is the canonical flow for server-side web apps.
Key design choices.
- The code travels through the browser. The token does not.
- The token exchange is server-to-server with the client_secret, so it cannot be intercepted by JS in the browser or a network sniffer (assuming TLS).
- The
stateparameter is a CSRF token for the OAuth flow itself. - The
redirect_urimust exactly match a pre-registered URL.
PKCE: For Public Clients
If your client cannot keep a secret (SPAs, native apps), the client_secret is meaningless because anyone can extract it from the JS bundle or the APK. PKCE (Proof Key for Code Exchange, RFC 7636) solves this.
The client generates a random code_verifier (43-128 bytes) per login attempt. It hashes it: code_challenge = base64url(SHA256(code_verifier)). It sends the challenge with the initial /authorize call. The auth server stores the challenge against the issued code.
When the client comes back to exchange the code, it sends the verifier. The auth server checks base64url(SHA256(verifier)) == stored_challenge. Only the original client that started the flow has the verifier, so only that client can complete the exchange.
This defeats code interception. If an attacker steals the code from a redirect (intercepted on a hostile WiFi, leaked log, redirect URI takeover), they cannot use it without the verifier.
RFC 9700 (OAuth 2.0 Security Best Current Practice) now recommends PKCE for all clients, even confidential server-side ones. Belt and suspenders.
Client Credentials Flow
For machine-to-machine, no user involved. Your service calls another service.
POST /token
grant_type=client_credentials
client_id=X
client_secret=SECRET
scope=read:invoices
Returns an access token. No user, no redirect, no code. Used for backend cron jobs hitting an API.
Device Code Flow
For TVs, CLIs, anything without a browser. You see this with gh auth login or Apple TV apps.
- Device calls /device_code, gets back a user_code (short, like ABCD-1234) and a device_code.
- Device shows: "Go to https://github.com/device and enter ABCD-1234."
- User does that on their phone or laptop.
- Device polls /token with the device_code. Initially gets
authorization_pending. Once the user completes the browser flow, it gets back tokens.
OpenID Connect
OIDC layers identity on top of OAuth. When you request the openid scope, the token response includes an id_token alongside the access_token.
{
"access_token": "ya29.a0...",
"id_token": "eyJhbG...",
"refresh_token": "1//0e...",
"expires_in": 3599
}The id_token is a JWT signed by the auth server. Decode it and you get user claims.
{
"iss": "https://accounts.google.com",
"sub": "1234567890",
"aud": "your_client_id",
"exp": 1700003600,
"iat": 1700000000,
"email": "user@example.com",
"email_verified": true,
"name": "Jane Doe"
}You verify the JWT signature against Google's public keys (fetched from /.well-known/openid-configuration and the JWKS URI). After verification, you trust the claims. That is the user, authenticated.
If you only need login (not API access), you can skip storing the access_token entirely. Verify the id_token, create your session, done.
Scopes
Scopes are the permission system. The client requests scopes, the user consents, the granted access_token can call APIs that require those scopes.
Naming conventions vary. Google uses URLs (https://www.googleapis.com/auth/calendar.readonly). GitHub uses dotted strings (repo:status, read:user). Most services have read/write tiers (read:invoices, write:invoices).
Design rules.
- Smallest scope possible. Do not request
repoif you only needpublic_repo. - Make scopes human-readable in the consent screen. "View your calendar events" beats "calendar.readonly."
- Avoid wildcard or admin scopes for production apps.
Refresh Tokens
Access tokens are short-lived (1 hour typical). When they expire, the client uses the refresh token to get a new access token without bothering the user.
POST /token
grant_type=refresh_token
refresh_token=OLD_REFRESH
client_id=X
client_secret=SECRET
Returns a new access_token and optionally a new refresh_token (refresh token rotation).
Security recommendations.
- Rotate refresh tokens on every use. If a leaked refresh token gets used by an attacker, the legitimate client's next refresh will fail, raising an alarm.
- Bind refresh tokens to the client (and ideally the device).
- Treat refresh token theft as account compromise.
Security Pitfalls That Have Burned Real Companies
The redirect_uri must be validated exactly. Substring matching is the OWASP top OAuth bug. If your validation does redirectUri.startsWith("https://yourapp.com"), an attacker registers https://yourapp.com.evil.com and steals codes.
The state parameter is non-negotiable. Without it, an attacker performs the OAuth flow with their own account, captures the redirect URL with the code, and tricks a victim into clicking it. The victim's browser hits your callback with the attacker's code, your app links the attacker's identity to the victim's account. The attacker now has access.
Mixing up the scope. Asking for too much causes users to abandon the consent screen. Asking for too little means you cannot do what you need and have to re-prompt later.
Storing access tokens in localStorage. XSS steals them. Backend session is safer.
PKCE downgrade. Some servers accept the code exchange without the verifier if no challenge was sent originally. An attacker who steals a code can complete the flow if the server is permissive. Always send the verifier.
Implementation Reality
Do not implement OAuth from scratch. The protocol has been the subject of dozens of CVEs in major providers (Facebook, Google, GitHub). Use a library.
- Server-side Node: NextAuth.js (now Auth.js), passport, openid-client.
- Python: authlib.
- Go: golang.org/x/oauth2.
- Self-hosted auth server: Keycloak, Ory Hydra, Auth0/Clerk if you want hosted.
Even with a library, validate scopes server-side. The library does the protocol, you decide what the access_token is allowed to do in your app.
Common Pitfalls
- Treating OAuth as authentication. Use OIDC for login.
- Not validating the
stateparameter. CSRF on the OAuth flow. - Loose redirect_uri matching. Use exact match including the path.
- Storing tokens in localStorage. XSS will steal them.
- Skipping PKCE on SPAs because "I'll add it later." Add it now.
- Long-lived access tokens. Use short access plus refresh.
Interview Soundbites
- "OAuth is authorization. OIDC is the authentication layer on top, with an ID token JWT."
- "Default to authorization code with PKCE. Implicit and password flows are deprecated."
- "State parameter is mandatory for CSRF protection on the flow itself."
- "Redirect URI must be exact match. Substring matching is how Facebook got owned in 2014."
Learn more
- Docs
- DocsRFC 7636: PKCEIETF
- Docs
- DocsOAuth 2.0 Simplified - Aaron PareckiAaron Parecki
- DocsOpenID Connect Core 1.0OpenID Foundation