OAuth2 Finally Made Sense โ The Flows, the Confusion, and the Mistakes Everyone Makes
Why OAuth2 is confusing on first encounter, what the authorization code flow actually does step by step, and the implementation mistakes I've seen in production.

Read the OAuth2 spec three times before it clicked. First time: bounced off the terminology. "Resource owner," "authorization server," "client" โ client? My backend server is the "client"? Second time: understood the words but couldn't see why it was designed this way. Why not just pass the password to the third-party app? Third time: finally understood that the entire point is to never give your password to anyone except the service that owns it. The complexity exists to solve a real security problem.
Here's the explanation I think I wish someone had given me, followed by the implementation mistakes that keep showing up in code reviews.
Why OAuth2 Exists โ The Actual Problem
Before OAuth, if you wanted a third-party app to access your Gmail contacts, you'd give that app your Google password. The app would log in as you, scrape the contacts page, and do what it needed. The app had your full Google password. It could read your email, delete your files, change your settings. You couldn't revoke its access without changing your password, which broke every other app you'd given it to.
OAuth2 solves this: the third-party app gets a limited-scope token instead of your password. The token can access your contacts but not your email. It expires. You can revoke it without changing your password. Google's login page handles the password โ the third-party app never sees it.
That's the entire concept. Everything else โ the flows, the tokens, the redirect URIs โ is the mechanism for making that concept work securely in different environments.
The Players
Four roles in every OAuth2 interaction. The names are confusing until you map them to real things.
Resource Owner: the user. You. The person who has the Gmail account.
Resource Server: the API that holds the data. Gmail's API.
Client: the application requesting access. The third-party contacts app. (This naming is why people get confused โ the "client" is often a backend server, not a browser.)
Authorization Server: the service that handles login and issues tokens. Google's OAuth server. Sometimes the same server as the Resource Server, sometimes separate.
Authorization Code Flow โ The One You Should Default To
This is the standard flow for web applications with a backend server. It's probably the most secure general-purpose flow and the one I use unless there's a specific reason not to.
Step by step, in actual sequence:
Step 1: User clicks "Login with Google" in your app. Your app redirects the browser to Google's authorization endpoint.
https://accounts.google.com/o/oauth2/v2/auth?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
scope=openid email profile&
state=random_csrf_token_abc123
Important parts: response_type=code says "give me an authorization code, not a token directly." scope defines what access you're requesting. state is a random value for CSRF protection โ your app generates it, stores it in the session, and checks it when the callback arrives.
Step 2: Google shows its login page. The user enters their Google credentials directly on Google's page. Your app never sees the password.
Step 3: Google asks the user to consent: "This app wants to see your email and profile. Allow?" The user approves (or denies).
Step 4: Google redirects the browser back to your app's callback URL with an authorization code.
https://yourapp.com/callback?
code=AUTH_CODE_xyz789&
state=random_csrf_token_abc123
The authorization code is short-lived (usually around 10 minutes, from what I've seen) and single-use. It's in the URL, which means it's visible in browser history and server logs. That's fine because it can't be used alone.
Step 5: Your backend server exchanges the authorization code for tokens by making a server-to-server request to Google. This request includes your client secret, which only your server knows.
import requests
def exchange_code(auth_code):
response = requests.post('https://oauth2.googleapis.com/token', data={
'grant_type': 'authorization_code',
'code': auth_code,
'redirect_uri': 'https://yourapp.com/callback',
'client_id': CLIENT_ID,
'client_secret': CLIENT_SECRET, # never exposed to browser
})
tokens = response.json()
access_token = tokens['access_token'] # use this to call APIs
refresh_token = tokens['refresh_token'] # use this to get new access tokens
id_token = tokens.get('id_token') # user info (if OpenID Connect)
return tokens
The access token is what you use to call the Gmail API. The refresh token lets you get a new access token when the current one expires without bothering the user again.
Step 6: Your app uses the access token to call the API.
headers = {'Authorization': f'Bearer {access_token}'}
profile = requests.get('https://www.googleapis.com/oauth2/v2/userinfo',
headers=headers)
user_data = profile.json()
# {'id': '12345', 'email': 'user@gmail.com', 'name': 'Anurag', ...}
The reason for the two-step dance (code first, then exchange for token) is security. The authorization code travels through the browser in the URL โ potentially visible. But it's useless without the client secret, which only travels server-to-server and never touches the browser. Even if someone intercepts the code, they can't get a token.
Authorization Code Flow with PKCE โ For Public Clients
PKCE (Proof Key for Code Exchange, pronounced "pixy") was originally designed for mobile apps and single-page applications that can't safely store a client secret. Now it's recommended for all OAuth2 clients, even confidential ones, as defense in depth.
The difference: instead of relying on a static client secret, the client generates a random value (code verifier) per request, sends a hash of it (code challenge) with the authorization request, and sends the original value with the token exchange. The authorization server verifies they match.
// Generate PKCE values
function generatePKCE() {
// Random 43-128 character string
const verifier = crypto.randomUUID() + crypto.randomUUID();
// SHA-256 hash, base64url encoded
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return { verifier, challenge };
}
// Step 1: Include challenge in authorization request
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier);
const authUrl = `https://auth.example.com/authorize?` +
`response_type=code&` +
`client_id=${CLIENT_ID}&` +
`redirect_uri=${REDIRECT_URI}&` +
`scope=openid profile&` +
`code_challenge=${challenge}&` +
`code_challenge_method=S256&` +
`state=${csrfToken}`;
window.location.href = authUrl;
// Step 5: Include verifier in token exchange
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier, // proves we initiated the request
})
});
Why this matters: without PKCE, an attacker who intercepts the authorization code (through a malicious browser extension, a compromised redirect, or a man-in-the-middle on the redirect URI) can exchange it for tokens. With PKCE, they'd also need the code verifier, which never traveled through the browser's URL โ it was stored in memory or session storage and sent directly in the POST body.
Client Credentials Flow โ Machine to Machine
No user involved. One server talks to another server. Your backend service needs to call a partner's API with its own identity, not on behalf of a user.
# Server gets its own access token
response = requests.post('https://auth.partner.com/token', data={
'grant_type': 'client_credentials',
'client_id': SERVICE_CLIENT_ID,
'client_secret': SERVICE_CLIENT_SECRET,
'scope': 'read:inventory write:orders'
})
token = response.json()['access_token']
# Use token for API calls - represents the application, not a user
Simplest flow. No browser redirect, no user interaction. Just "here are my credentials, give me a token." The security consideration: the client secret must be stored securely. Environment variables, a secrets manager, not hardcoded in source code. I've written about broader API security practices in my API security post that covers token storage and secrets management.
The Implicit Flow โ Don't Use It
The implicit flow returns an access token directly in the URL fragment after authorization. No intermediate code, no backend exchange. It was designed for browser-based apps before PKCE existed.
// DON'T DO THIS - implicit flow
https://yourapp.com/callback#access_token=TOKEN_HERE&token_type=bearer
The token is in the URL. Browser history. Referrer headers. Server logs on any page the user navigates to next. Interceptable. The OAuth 2.1 draft specification removes the implicit flow entirely. Use authorization code with PKCE for browser apps instead. If you're maintaining legacy code that uses implicit flow, migrating away from it should be on the roadmap.
Common Implementation Mistakes
These show up in code reviews more often than I'd like.
Not Validating the State Parameter
# BAD: ignoring state parameter
@app.route('/callback')
def callback():
code = request.args.get('code')
tokens = exchange_code(code) # CSRF vulnerable!
# GOOD: validate state matches what we set
@app.route('/callback')
def callback():
state = request.args.get('state')
expected_state = session.pop('oauth_state', None)
if state != expected_state:
abort(403, 'CSRF validation failed')
code = request.args.get('code')
tokens = exchange_code(code)
Without state validation, an attacker can craft a URL that sends an authorization code for their own account to your callback. The victim's browser follows the URL, your app exchanges the code, and now the victim's session is linked to the attacker's account. This is a real attack, as far as I know โ CSRF against OAuth callbacks.
Storing Tokens Insecurely
// BAD: access token in localStorage - accessible to any JavaScript
localStorage.setItem('access_token', token);
// BAD: access token in a regular cookie - sent on every request, CSRF risk
document.cookie = `access_token=${token}`;
// BETTER: httpOnly, secure, sameSite cookie - not accessible to JavaScript
// Set this from the server side:
// Set-Cookie: session=encrypted_session_id; HttpOnly; Secure; SameSite=Lax; Path=/
Access tokens in localStorage are accessible to any JavaScript running on the page, including XSS payloads. A single XSS vulnerability probably exposes every user's access token. HttpOnly cookies can't be read by JavaScript. SameSite prevents CSRF. Secure prevents transmission over HTTP. The token should be in an HttpOnly cookie or, better, the backend should hold the token and use an opaque session ID in the cookie.
Not Handling Token Expiration
# BAD: use token until it fails, then... what?
def get_user_data(access_token):
response = requests.get(API_URL, headers={'Authorization': f'Bearer {access_token}'})
return response.json() # crashes when token expires
# GOOD: proactive refresh with fallback
def get_user_data(user_id):
token_data = get_stored_tokens(user_id)
if token_data['expires_at'] < time.time() + 300: # refresh 5 min early
token_data = refresh_access_token(token_data['refresh_token'])
store_tokens(user_id, token_data)
response = requests.get(API_URL,
headers={'Authorization': f'Bearer {token_data["access_token"]}'})
if response.status_code == 401:
# Token revoked or refresh failed - re-authenticate
raise TokenExpiredError("User needs to re-authenticate")
return response.json()
Access tokens expire. Usually in an hour. If your code doesn't handle expiration, users get random failures. Refresh proactively before expiration (I use a 5-minute buffer), and handle the case where the refresh token itself is revoked.
Overly Broad Scopes
# BAD: request everything "just in case"
scope = "openid email profile contacts calendar drive admin"
# GOOD: request only what you need right now
scope = "openid email profile"
# Request additional scopes incrementally when the user needs that feature
Requesting broad scopes makes the consent screen scary. "This app wants to access your email, contacts, calendar, files, and admin settings" โ users, I think, rightfully decline. Request minimal scopes initially. When the user tries a feature that needs additional access, use incremental authorization to request just that scope.
Not Validating the Redirect URI Strictly
On the authorization server side (if you're building one):
# BAD: prefix matching
if redirect_uri.startswith('https://yourapp.com'):
allow()
# Allows https://yourapp.com.evil.com!
# BAD: substring matching
if 'yourapp.com' in redirect_uri:
allow()
# Allows https://evil.com/?fake=yourapp.com!
# GOOD: exact match against registered URIs
REGISTERED_URIS = {'https://yourapp.com/callback', 'https://yourapp.com/auth/callback'}
if redirect_uri in REGISTERED_URIS:
allow()
Redirect URI validation must be exact string matching. Any pattern matching (prefix, subdomain, regex) can be bypassed. Open redirects on the authorization server are critical vulnerabilities โ the attacker gets the authorization code sent to their controlled domain.
OAuth2 vs OpenID Connect
OAuth2 is about authorization โ granting an application access to your resources. It doesn't actually tell the application who you are.
OpenID Connect (OIDC) is a layer on top of OAuth2 that adds authentication โ it tells the application who you are. When you request the openid scope, the authorization server returns an ID token (a JWT) alongside the access token. The ID token contains claims about the user โ email, name, picture.
import jwt
def verify_id_token(id_token):
# Fetch the authorization server's public keys
jwks = requests.get('https://auth.example.com/.well-known/jwks.json').json()
# Decode and verify the token
decoded = jwt.decode(
id_token,
key=jwks, # verify signature with server's public key
algorithms=['RS256'],
audience=CLIENT_ID, # verify it's meant for us
issuer='https://auth.example.com' # verify who issued it
)
return {
'user_id': decoded['sub'],
'email': decoded['email'],
'name': decoded['name']
}
The ID token is a signed JWT. Your application verifies the signature using the authorization server's public key. If the signature is valid, the claims are trustworthy โ you know the user is who the token says they are without making an additional API call.
Always verify the signature, audience, and issuer. Accepting an unverified JWT means trusting any value the requester puts in it. A common mistake: decoding the JWT without verification to "check the user's email." Anyone can create a JWT with any email. Verification is what makes it trustworthy.
Token Storage Architecture
For web applications, my preferred architecture after several iterations:
Browser <-> Your Backend <-> Authorization Server
|
v
Token Store (encrypted, server-side)
The browser holds an opaque session cookie (HttpOnly, Secure, SameSite). The backend holds the access and refresh tokens, encrypted at rest, associated with the session. The browser never sees the OAuth tokens.
When the frontend needs to call a protected API, it calls your backend, which attaches the access token and proxies the request. This keeps tokens off the client entirely.
@app.route('/api/user/profile')
def get_profile():
session_id = request.cookies.get('session_id')
tokens = get_tokens_for_session(session_id)
if not tokens:
return jsonify({'error': 'not authenticated'}), 401
# Backend makes the API call with the token
response = requests.get('https://api.provider.com/profile',
headers={'Authorization': f'Bearer {tokens["access_token"]}'})
return jsonify(response.json())
More complex than putting the token in localStorage. More secure by a wide margin. XSS can't steal what it can't access.
When to Use Which Flow
Web app with a backend: Authorization Code flow (with PKCE as defense in depth). The backend holds the client secret and tokens. This is the default choice.
Single-page application (no backend): Authorization Code with PKCE. No client secret (public client). Store tokens carefully โ consider a Backend for Frontend (BFF) pattern where a lightweight backend holds the tokens.
Mobile app: Authorization Code with PKCE. Use the system browser for the authorization redirect (not an in-app WebView โ the WebView can intercept the password). Store tokens in the platform's secure storage (Keychain on iOS, Keystore on Android).
Server-to-server: Client Credentials flow. No user involvement. Straightforward.
Never: Implicit flow. Resource Owner Password Credentials flow (sends the user's password through your app โ defeats the entire purpose of OAuth2). Both are deprecated in OAuth 2.1.
OAuth2 is confusing because it solves a genuinely complex problem across many different environments. The terminology is dense, the spec is abstract, and most tutorials either oversimplify or drown you in edge cases. But the core idea โ limited, revocable access tokens instead of sharing passwords โ is worth the complexity. Get the authorization code flow with PKCE working correctly, validate everything the spec tells you to validate, keep tokens server-side, and you've handled 90% of what most applications need.
Further Resources
- RFC 6749 โ The OAuth 2.0 Authorization Framework โ The original specification that defines the OAuth2 protocol, its flows, and security considerations.
- OAuth.net โ A community resource with plain-language explanations of OAuth2 concepts, PKCE, and links to libraries for every major language.
- OpenID Connect Specification โ The official spec for OIDC, the authentication layer built on top of OAuth2 that handles identity verification.
Written by
Anurag Sinha
Full-stack developer specializing in React, Next.js, cloud infrastructure, and AI. Writing about web development, DevOps, and the tools I actually use in production.
Stay Updated
New articles and tutorials sent to your inbox. No spam, no fluff, unsubscribe whenever.
I send one email per week, max. Usually less.
Comments
Loading comments...
Related Articles

API Security Best Practices: A Direct Technical Breakdown
Authentication, authorization, rate limiting, and input validation security mechanics.

Linux Server Hardening โ After the First SSH In
The steps I take on every new Linux server before it faces the internet: SSH lockdown, firewall rules, fail2ban, automatic updates, and the security that actually matters.

Cybersecurity Survival: A Practical Scenario
Walk through a simulated breach to understand which skills actually matter in real-world incident response.