OAuth 2.0 is the protocol behind every "Sign in with Google / GitHub / Facebook" button on the web. It lets users grant an application access to their data on another service — without giving that application their password.
This guide explains how OAuth 2.0 works, which flow to use, and how to implement it.
What Problem Does OAuth 2.0 Solve?
Before OAuth, the only way to let a third-party app access your data was to give it your username and password. That's terrible:
- The app stores your credentials
- You can't revoke access without changing your password
- A breached app exposes your credentials everywhere
OAuth 2.0 solves this with access tokens: short-lived credentials that grant limited access to specific resources, without exposing your password.
Core Concepts
| Term | Meaning |
|---|---|
| Resource Owner | The user who owns the data |
| Client | The application requesting access |
| Authorization Server | Verifies identity and issues tokens (e.g. Google, GitHub) |
| Resource Server | The API that holds the user's data |
| Access Token | Short-lived credential for API calls |
| Refresh Token | Long-lived credential to get new access tokens |
| Scope | What the client is allowed to access (email, read:repos) |
| Authorization Code | One-time code exchanged for tokens |
The Four OAuth 2.0 Grant Types
OAuth 2.0 defines four ways ("flows") to obtain an access token, each for a different use case.
Quick Reference
| Flow | Use Case | Security Level |
|---|---|---|
| Authorization Code | Web apps with a backend server | High |
| Authorization Code + PKCE | SPAs, mobile apps, CLI tools | High |
| Client Credentials | Machine-to-machine (no user) | High |
| Device Code | Smart TVs, CLI, input-constrained devices | High |
| Low — don't use | ||
| Low — don't use |
Flow 1: Authorization Code (Web Apps with Backend)
This is the most common flow. A backend server handles tokens — they never touch the browser.
Step-by-Step
User → Client App → Authorization Server → User (login + consent)
← Authorization Code ←
Client App → Authorization Server (code + client_secret)
← Access Token + Refresh Token ←
Client App → Resource Server (Access Token)
← Protected Data ←
Step 1 — Redirect User to Authorization Server
https://authorization-server.com/auth?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
scope=profile%20email&
state=RANDOM_STATE_STRING
Always include state — it's a CSRF token that you verify when the callback arrives.
Step 2 — User Logs In and Grants Consent
The authorization server shows a login and consent screen. After the user approves:
https://yourapp.com/callback?code=AUTH_CODE&state=RANDOM_STATE_STRING
Step 3 — Exchange Code for Tokens (Server-Side)
POST https://authorization-server.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://yourapp.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
Response:
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBtZp8",
"scope": "profile email"
}
Step 4 — Call the API
GET https://api.resource-server.com/me
Authorization: Bearer eyJhbGci...
Flow 2: Authorization Code + PKCE (SPAs and Mobile Apps)
SPAs and mobile apps can't keep a client_secret secret — it would be visible in the source code. PKCE (Proof Key for Code Exchange, pronounced "pixie") solves this without a client secret.
How PKCE Works
- Generate a random code verifier (43–128 character random string)
- Hash it with SHA-256 to create a code challenge
- Send the code challenge during the authorization request
- Send the original code verifier during the token exchange
The authorization server can verify you're the same client that started the flow — even without a client secret.
Implementation (JavaScript / SPA)
// 1. Generate code verifier and challenge
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// 2. Start OAuth flow
async function startOAuth() {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// Store verifier for the callback
sessionStorage.setItem('code_verifier', verifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'https://yourapp.com/callback',
scope: 'profile email',
state: crypto.randomUUID(), // CSRF protection
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location.href = `https://auth-server.com/auth?${params}`;
}
// 3. Handle callback — exchange code for tokens
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const verifier = sessionStorage.getItem('code_verifier');
const response = await fetch('https://auth-server.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://yourapp.com/callback',
client_id: 'YOUR_CLIENT_ID',
code_verifier: verifier, // No client_secret needed!
}),
});
const tokens = await response.json();
// Store access_token in memory (NOT localStorage)
return tokens;
}
Flow 3: Client Credentials (Machine-to-Machine)
When there's no user involved — a background job calling an API, a microservice authenticating to another service.
POST https://authorization-server.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
scope=read:reports
Response:
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:reports"
}
No user, no redirect, no consent screen — just credentials exchanged for a token.
Node.js Example
async function getMachineToken() {
const response = await fetch('https://auth-server.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
scope: 'read:reports',
}),
});
const { access_token, expires_in } = await response.json();
return { access_token, expiresAt: Date.now() + expires_in * 1000 };
}
Flow 4: Device Code (Smart TVs, CLI Tools)
For devices with no browser or limited input. The user completes the flow on a different device (their phone or computer).
POST https://authorization-server.com/device/code
Content-Type: application/x-www-form-urlencoded
client_id=YOUR_CLIENT_ID&scope=profile
Response:
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth-server.com/activate",
"expires_in": 1800,
"interval": 5
}
- Show user: "Go to https://auth-server.com/activate and enter code WDJB-MJHT"
- Poll
/tokeneveryintervalseconds until the user completes the flow - When the user finishes, the poll returns
access_tokenandrefresh_token
Refresh Tokens
Access tokens are short-lived (typically 1 hour). Refresh tokens let you get new access tokens without making the user log in again.
POST https://authorization-server.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&
refresh_token=8xLOxBtZp8&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
Response: new access_token (and sometimes a new refresh_token).
Refresh Token Rotation
Many providers rotate refresh tokens — each use invalidates the old one and returns a new one. Store the latest refresh token securely. If an old refresh token is used, the authorization server may invalidate the entire family (detecting token theft).
Full Node.js Implementation (Authorization Code Flow)
import express from 'express';
import crypto from 'crypto';
import fetch from 'node-fetch';
const app = express();
// In production: use a proper session library
const sessions = new Map();
const config = {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
redirectUri: 'http://localhost:3000/callback',
authorizationUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scope: 'read:user user:email',
};
// Start OAuth flow
app.get('/login', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
// Store state in session for CSRF protection
const sessionId = crypto.randomBytes(16).toString('hex');
sessions.set(sessionId, { state });
res.cookie('session_id', sessionId, { httpOnly: true, sameSite: 'lax' });
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scope,
state,
});
res.redirect(`${config.authorizationUrl}?${params}`);
});
// OAuth callback
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
const sessionId = req.cookies?.session_id;
const session = sessions.get(sessionId);
// Verify state to prevent CSRF
if (!session || session.state !== state) {
return res.status(400).send('Invalid state');
}
sessions.delete(sessionId);
// Exchange code for tokens
const tokenResponse = await fetch(config.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
}),
});
const { access_token, error } = await tokenResponse.json();
if (error) return res.status(400).json({ error });
// Fetch user info
const userResponse = await fetch(config.userInfoUrl, {
headers: { Authorization: `Bearer ${access_token}` },
});
const user = await userResponse.json();
res.json({ user, access_token });
});
app.listen(3000);
Python Implementation (Authorization Code Flow with Flask)
import os, secrets, requests
from flask import Flask, redirect, request, session, jsonify, url_for
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET"]
GITHUB_CLIENT_ID = os.environ["GITHUB_CLIENT_ID"]
GITHUB_CLIENT_SECRET = os.environ["GITHUB_CLIENT_SECRET"]
GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"
GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
GITHUB_USER_URL = "https://api.github.com/user"
@app.route("/login")
def login():
state = secrets.token_hex(16)
session["oauth_state"] = state
params = {
"client_id": GITHUB_CLIENT_ID,
"redirect_uri": url_for("callback", _external=True),
"scope": "read:user user:email",
"state": state,
}
auth_url = requests.Request("GET", GITHUB_AUTH_URL, params=params).prepare().url
return redirect(auth_url)
@app.route("/callback")
def callback():
# Verify state
if request.args.get("state") != session.pop("oauth_state", None):
return "Invalid state", 400
code = request.args.get("code")
# Exchange code for tokens
token_response = requests.post(
GITHUB_TOKEN_URL,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": url_for("callback", _external=True),
"client_id": GITHUB_CLIENT_ID,
"client_secret": GITHUB_CLIENT_SECRET,
},
headers={"Accept": "application/json"},
)
tokens = token_response.json()
access_token = tokens.get("access_token")
if not access_token:
return jsonify({"error": tokens.get("error_description")}), 400
# Fetch user info
user_response = requests.get(
GITHUB_USER_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
user = user_response.json()
# In production: store user in database and create a session
session["user_id"] = user["id"]
return jsonify(user)
OpenID Connect (OIDC): OAuth 2.0 for Authentication
OAuth 2.0 handles authorization (access to resources). OpenID Connect (OIDC) is a layer on top that adds authentication (identity).
OIDC adds an ID token (a JWT) to the authorization code flow:
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "812741506391.apps.googleusercontent.com",
"email": "user@example.com",
"name": "Jane Doe",
"iat": 1353601026,
"exp": 1353604926
}
The ID token tells you who the user is. The access token tells the resource server what the client is allowed to do.
| OAuth 2.0 | OpenID Connect | |
|---|---|---|
| Purpose | Authorization | Authentication + Authorization |
| Token format | Opaque or JWT | ID Token (always JWT) + Access Token |
| User info endpoint | No standard | Standardized /userinfo |
| Common use | API access, scopes | Login, SSO |
Most modern "Sign in with X" implementations use OIDC.
OAuth vs JWT: What's the Difference?
These are not alternatives — they solve different problems and are often used together.
| OAuth 2.0 | JWT | |
|---|---|---|
| What it is | Authorization protocol | Token format |
| Defines | How to obtain tokens | What a token looks like |
| Tokens | Can use any format | Is itself the token format |
| Purpose | Delegated access | Compact, self-contained claims |
| Relationship | OAuth can issue JWTs as access tokens | JWTs can be used as OAuth tokens |
OAuth 2.0 says "here's how to get a token." JWT says "here's what the token looks like."
Security Best Practices
Always Do
- Validate
stateparameter on callback to prevent CSRF - Use HTTPS — tokens in transit over HTTP are stolen instantly
- Use short-lived access tokens (15 min — 1 hour)
- Store tokens securely: memory or
httpOnlycookies (notlocalStorage— vulnerable to XSS) - Use PKCE for all public clients (SPAs, mobile, CLI)
- Validate the
iss,aud, andexpclaims in ID tokens (OIDC) - Use exact redirect URI matching — don't allow wildcards
Never Do
Store tokens in— XSS can steal themlocalStorageUse the Implicit flow— deprecated; use Authorization Code + PKCE insteadUse the Resource Owner Password Credentials flow— exposes user passwords to your appSkip— enables CSRF attacksstatevalidationLog access tokens— treat them like passwords
Token Storage Recommendations
| Client Type | Access Token | Refresh Token |
|---|---|---|
| Traditional web app (backend session) | Server-side session | Server-side (database/Redis) |
| SPA (React, Vue, Angular) | In-memory (JS variable) | httpOnly cookie |
| Mobile app | Secure storage (Keychain/Keystore) | Secure storage |
| CLI tool | File with restricted permissions | Same file |
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
Skipping state validation |
CSRF attack can steal authorization code | Always validate state on callback |
| Using Implicit flow | Access token exposed in URL fragment, no refresh token | Use Authorization Code + PKCE |
Storing tokens in localStorage |
XSS can steal tokens | Use httpOnly cookies or in-memory |
Accepting alg: none in ID tokens |
Forged tokens bypass verification | Always require HS256 or RS256 |
| Using wildcard redirect URIs | Attacker can redirect code to their server | Exact URI match only |
| Long-lived access tokens | Compromised token is valid for too long | 1 hour max; use refresh tokens |
| No token revocation | Stolen tokens remain valid | Implement revocation endpoint or short expiry |
Logging Authorization headers |
Tokens leak to log aggregators | Redact auth headers in logs |
FAQ
Q: Do I need to build my own OAuth server?
Rarely. Use a provider (Google, GitHub, Auth0, Okta, Keycloak, Supabase Auth) or an identity library (Passport.js, NextAuth.js, Flask-Dance). Building a spec-compliant OAuth server from scratch is complex and error-prone.
Q: What's the difference between authorization and authentication?
- Authentication: Who are you? (Login)
- Authorization: What are you allowed to do? (Permissions)
OAuth 2.0 is an authorization protocol. OpenID Connect (built on OAuth) adds authentication.
Q: Why does GitHub OAuth not return a refresh token?
Some providers (GitHub, Slack) issue long-lived access tokens instead of short-lived tokens + refresh tokens. It simplifies the flow but means you can't silently renew access — the user must re-authenticate if the token is revoked.
Q: How is OAuth 2.0 different from OAuth 1.0?
OAuth 1.0 required request signing (HMAC signatures on every API call) — complex to implement correctly. OAuth 2.0 relies entirely on HTTPS for transport security and uses bearer tokens, making it much simpler to implement. OAuth 1.0 is considered obsolete.
Q: When should I use Client Credentials vs Authorization Code?
- Client Credentials: No user is involved (background services, microservices communicating, cron jobs calling APIs)
- Authorization Code: A human user needs to grant access
Q: Can I use OAuth 2.0 for my own API?
Yes. You can both act as a client (consuming someone else's OAuth) and as an authorization server (issuing tokens for your own APIs). Libraries like node-oauth2-server, Authlib (Python), or hosted services like Auth0 make this practical without implementing the full spec yourself.