Toolmingo
Guides11 min read

OAuth 2.0 Explained: How Authorization Works (with Examples)

Learn how OAuth 2.0 works, the four grant types, PKCE for SPAs and mobile apps, and how to implement OAuth login in Node.js and Python — with security best practices.

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
Implicit SPAs (deprecated) Low — don't use
Resource Owner Password Direct login (deprecated) 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

  1. Generate a random code verifier (43–128 character random string)
  2. Hash it with SHA-256 to create a code challenge
  3. Send the code challenge during the authorization request
  4. 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
}
  1. Show user: "Go to https://auth-server.com/activate and enter code WDJB-MJHT"
  2. Poll /token every interval seconds until the user completes the flow
  3. When the user finishes, the poll returns access_token and refresh_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 state parameter 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 httpOnly cookies (not localStorage — vulnerable to XSS)
  • Use PKCE for all public clients (SPAs, mobile, CLI)
  • Validate the iss, aud, and exp claims in ID tokens (OIDC)
  • Use exact redirect URI matching — don't allow wildcards

Never Do

  • Store tokens in localStorage — XSS can steal them
  • Use the Implicit flow — deprecated; use Authorization Code + PKCE instead
  • Use the Resource Owner Password Credentials flow — exposes user passwords to your app
  • Skip state validation — enables CSRF attacks
  • Log 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.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools