DevToolBoxZA DARMO
Blog

OAuth 2.0 Complete Guide: Authorization Code, PKCE, OpenID Connect, JWT, and Security Best Practices

14 min readby DevToolBox
TL;DR

OAuth 2.0 is a delegation framework — it lets users grant third-party apps limited access to their resources without sharing passwords. Use Authorization Code + PKCE for SPAs and mobile apps, Client Credentials for machine-to-machine, and OpenID Connect (OIDC) on top of OAuth 2.0 when you need identity (who the user is, not just what they can access).

Key Takeaways
  • OAuth 2.0 is for authorization (access); OpenID Connect adds authentication (identity) on top.
  • Always use Authorization Code + PKCE for browser and mobile apps — the Implicit flow is deprecated.
  • Access tokens should be short-lived (15 min); refresh tokens can be long-lived with rotation.
  • Store tokens in httpOnly cookies to protect against XSS; use CSRF tokens to protect against CSRF.
  • Always validate the state parameter to prevent CSRF in the OAuth callback.
  • JWT access tokens are self-contained — verify signature, expiry, issuer, and audience on every request.

What Is OAuth 2.0 and Why Was It Created?

Before OAuth, delegating API access was painful. If you wanted a third-party app to access your Google Drive, you had to give it your Google username and password. The app had full access to your entire account, you could not revoke access without changing your password, and the third party could do anything a human could do.

OAuth 2.0 (RFC 6749, published 2012) solved this with a delegation model. Instead of sharing credentials, the user grants a third-party application a limited, scoped access token. The user approves specific permissions (scopes), the token expires automatically, and the user can revoke access at any time without changing their password.

OAuth 2.0 defines four roles: the Resource Owner (the user), the Client (the third-party application), the Authorization Server (the service that issues tokens, e.g., Google's auth server), and the Resource Server (the API holding the protected resources, e.g., Google Drive API). These roles communicate through a series of redirects and back-channel requests.

The key insight of OAuth 2.0 is that the user's credentials never reach the third-party application. The user authenticates directly with the Authorization Server (which they trust), and only the resulting token is handed to the client.

OAuth 2.0 vs OAuth 1.0 vs OpenID Connect

OAuth 1.0 (2007) required complex cryptographic request signing (HMAC-SHA1) for every API call. Every request had to include a timestamp, nonce, and signature computed from the request parameters and a shared secret. While secure, it was extremely difficult to implement correctly and was a poor developer experience.

OAuth 2.0 dropped mandatory request signing in favor of TLS (HTTPS) for transport security. This made implementation far simpler but introduced new complexity through the proliferation of grant types. OAuth 2.0 is a framework, not a protocol — implementations can vary significantly between providers.

OpenID Connect (OIDC) is a thin identity layer built on top of OAuth 2.0. Where OAuth 2.0 only handles authorization (can this app access this resource?), OIDC adds authentication (who is this user?). OIDC introduces the ID token — a JWT containing identity claims like name, email, and profile picture. If you need to know who the user is (not just that they authorized your app), use OIDC.

Quick Comparison

ProtocolPurposeToken FormatBest For
OAuth 1.0AuthorizationOpaqueLegacy systems requiring request signing
OAuth 2.0AuthorizationOpaque or JWTAPI access delegation, modern apps
OpenID ConnectAuthentication + AuthorizationJWT (ID token)User identity + API access
SAML 2.0Authentication + AuthorizationXML AssertionEnterprise SSO with Active Directory

Key Concepts: Roles, Scopes, and Tokens

The Four OAuth 2.0 Roles

Every OAuth 2.0 interaction involves four roles. The Resource Owner is typically the end user who owns the data. The Client is the application requesting access (your web app, mobile app, or backend service). The Authorization Server issues access tokens after authenticating the resource owner and obtaining authorization. The Resource Server hosts the protected resources and validates access tokens on each request.

Resource Owner

The user who owns the data and grants access

Client

The application requesting access to the resource

Authorization Server

Issues tokens after authenticating the user

Resource Server

Hosts protected resources, validates tokens

Scopes

Scopes define the permissions being requested. They are space-separated strings that the client requests and the user approves. Examples include read:user, repo, openid, email, and profile. Well-designed OAuth servers use fine-grained scopes so users can grant minimal necessary permissions. Always request only the scopes your application actually needs.

scope=openid email profile read:user repo write:calendar

Token Types

OAuth 2.0 uses three types of tokens. Access tokens are short-lived credentials (typically 15-60 minutes) that the client includes in API requests. Refresh tokens are long-lived credentials (days to months) used to obtain new access tokens without user re-authentication. ID tokens (OIDC only) are JWTs containing user identity claims — they are for the client to read, not for sending to resource servers.

Grant Types: Which Flow Should You Use?

OAuth 2.0 defines multiple grant types (flows) for different client types and use cases. Picking the wrong grant type is the most common OAuth mistake.

Authorization Code Flow

The most widely used and most secure flow for server-side web applications. The client redirects the user to the Authorization Server, the user authenticates and consents, and the Authorization Server redirects back to the client with a short-lived authorization code. The client then exchanges this code for tokens via a direct server-to-server POST request. The access token never touches the browser.

Use when: You have a server-side component that can securely store the client_secret. Node.js, Python, Ruby, Java, and PHP web apps all qualify. The back-channel token exchange prevents the access token from appearing in browser history or logs.

Implicit Flow (Deprecated)

The Implicit flow was designed for SPAs that could not make server-side requests. The authorization server returned the access token directly in the URL fragment (#access_token=...). This was convenient but dangerous — tokens in URL fragments appear in browser history, referrer headers, and server logs. The Implicit flow is now deprecated by RFC 9700. Use Authorization Code + PKCE instead for all browser-based apps.

Client Credentials Flow

For machine-to-machine (M2M) communication where no user is involved. The client authenticates directly with the Authorization Server using its client_id and client_secret, and receives an access token. This is appropriate for backend services, cron jobs, microservice-to-microservice communication, and CI/CD pipelines. There is no redirect and no user interaction.

// Client Credentials Flow — Machine-to-Machine
// Node.js example

async function getM2MToken() {
  const response = await fetch('https://auth.example.com/oauth/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 write:data',
    }),
  });

  const { access_token, expires_in } = await response.json();

  // Cache the token until it expires (minus a buffer)
  tokenCache.set('m2m_token', {
    token: access_token,
    expiresAt: Date.now() + (expires_in - 60) * 1000, // 60s buffer
  });

  return access_token;
}

async function getCachedM2MToken() {
  const cached = tokenCache.get('m2m_token');
  if (cached && cached.expiresAt > Date.now()) {
    return cached.token;
  }
  return getM2MToken();
}

// Usage in a service call
async function fetchInternalData() {
  const token = await getCachedM2MToken();
  const res = await fetch('https://internal-api.example.com/reports', {
    headers: { Authorization: 'Bearer ' + token },
  });
  return res.json();
}

Device Authorization Flow

Designed for devices with limited input capabilities (smart TVs, gaming consoles, CLI tools). The device displays a short code and a URL. The user visits the URL on their phone or computer, enters the code, and authenticates. The device polls the Authorization Server until the user completes authentication. Used by tools like GitHub CLI and the AWS CLI.

Authorization Code + PKCE (Recommended for SPAs and Mobile)

PKCE (Proof Key for Code Exchange, pronounced "pixy") is an extension to the Authorization Code flow that eliminates the need for a client_secret for public clients. The client generates a random code_verifier, hashes it to produce a code_challenge, and sends the challenge with the authorization request. When exchanging the code for tokens, the client sends the original code_verifier. The server verifies that hash(code_verifier) == code_challenge, proving the token request came from the same client that started the flow.

Use PKCE for: All single-page applications (SPAs), all mobile applications (iOS/Android), all native desktop applications, and any client that cannot securely store a client_secret. As of 2024, PKCE is recommended even for confidential clients (server-side apps) as an additional layer of protection.
// Authorization Code + PKCE — Complete Implementation
// Works for SPAs, mobile apps, and server-side apps

// Step 1: Generate PKCE code_verifier and code_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, '');
}

// Step 2: Build authorization URL and redirect
async function startOAuthFlow() {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  const state = crypto.randomUUID(); // CSRF protection

  // Store verifier and state securely (sessionStorage is OK for this)
  sessionStorage.setItem('pkce_verifier', codeVerifier);
  sessionStorage.setItem('oauth_state', state);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'YOUR_CLIENT_ID',
    redirect_uri: 'https://yourapp.com/callback',
    scope: 'openid email profile',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });

  window.location.href = 'https://accounts.google.com/o/oauth2/v2/auth?' + params;
}

// Step 3: Handle callback — exchange code for tokens
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');
  const error = params.get('error');

  if (error) throw new Error('OAuth error: ' + error);

  // Validate state to prevent CSRF
  const storedState = sessionStorage.getItem('oauth_state');
  if (state !== storedState) throw new Error('State mismatch — possible CSRF attack');

  const codeVerifier = sessionStorage.getItem('pkce_verifier');
  sessionStorage.removeItem('pkce_verifier');
  sessionStorage.removeItem('oauth_state');

  // Exchange code for tokens
  const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: 'YOUR_CLIENT_ID',
      redirect_uri: 'https://yourapp.com/callback',
      code,
      code_verifier: codeVerifier,
    }),
  });

  const tokens = await tokenResponse.json();
  // tokens.access_token, tokens.refresh_token, tokens.id_token
  return tokens;
}
Grant TypeClient TypeUser InteractionStatus
Authorization Code + PKCESPA, Mobile, ServerYesRecommended
Authorization CodeServer-sideYesRecommended
Client CredentialsBackend serviceNo (M2M)Recommended
Device FlowTV, CLI, IoTYes (on other device)Supported
ImplicitBrowser (legacy)YesDeprecated
Password (ROPC)Trusted 1st-partyYes (credentials)Deprecated

Access Tokens vs Refresh Tokens vs ID Tokens

Access Token

Access tokens are bearer credentials — whoever holds them can use them. They are typically short-lived (15 minutes to 1 hour) to limit the damage from theft. The client includes the access token in every API request via the Authorization: Bearer header. Access tokens may be opaque strings (the resource server validates them by calling an introspection endpoint) or self-contained JWTs (the resource server validates them locally using the public key).

Refresh Token

Refresh tokens are long-lived credentials (1 day to 1 year, or non-expiring) that the client uses to obtain a new access token when the current one expires. Unlike access tokens, refresh tokens are only sent to the Authorization Server, never to the Resource Server. They must be stored securely. Refresh token rotation (issuing a new refresh token each time one is used) prevents indefinite reuse of a stolen refresh token.

ID Token (OIDC)

ID tokens are JWTs issued by OpenID Connect providers. They contain identity claims (sub, name, email, picture, etc.) about the authenticated user. ID tokens are meant to be read by the client application — not sent to the Resource Server. To authenticate API requests, use the access token. The ID token answers "Who is this user?" while the access token answers "What can this app do?"

Token Lifecycle and Refresh

// Token refresh with rotation — Express.js backend
const jwt = require('jsonwebtoken');

// In-memory store (use Redis or database in production)
const refreshTokenStore = new Map();

app.post('/auth/token/refresh', async (req, res) => {
  const { refresh_token } = req.body;
  if (!refresh_token) {
    return res.status(400).json({ error: 'refresh_token required' });
  }

  let payload;
  try {
    payload = jwt.verify(refresh_token, process.env.REFRESH_SECRET);
  } catch {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  const stored = refreshTokenStore.get(payload.sub);
  if (!stored || stored.token !== refresh_token) {
    // Token reuse detected — invalidate family
    refreshTokenStore.delete(payload.sub);
    return res.status(401).json({ error: 'Refresh token reuse detected' });
  }

  // Issue new access token
  const accessToken = jwt.sign(
    { sub: payload.sub, email: payload.email, scope: payload.scope },
    process.env.ACCESS_SECRET,
    { expiresIn: '15m', issuer: 'https://api.example.com', audience: 'https://api.example.com' }
  );

  // Rotate refresh token
  const newRefreshToken = jwt.sign(
    { sub: payload.sub, email: payload.email, scope: payload.scope },
    process.env.REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  refreshTokenStore.set(payload.sub, {
    token: newRefreshToken,
    issuedAt: Date.now(),
  });

  res.json({
    access_token: accessToken,
    refresh_token: newRefreshToken,
    token_type: 'Bearer',
    expires_in: 900,
  });
});

JWT Structure and Verification

JSON Web Tokens (JWT, RFC 7519) are the most common format for OAuth 2.0 access tokens and OIDC ID tokens. A JWT consists of three Base64URL-encoded parts separated by dots: Header.Payload.Signature.

HeaderPayloadSignature
{"alg":"RS256","typ":"JWT"}{"sub":"1234","email":"user@example.com","exp":1735689600}RSASHA256(base64url(header)+"."+base64url(payload), privateKey)

The Header specifies the signing algorithm (alg) and token type (typ). Common algorithms are HS256 (HMAC-SHA256, symmetric — same key for signing and verification) and RS256 (RSA-SHA256, asymmetric — private key signs, public key verifies). For distributed systems, RS256 is preferred because resource servers can verify tokens using the public key without needing the private key.

The Payload contains claims — statements about the user and the token. Registered claims include iss (issuer), sub (subject/user ID), aud (audience), exp (expiration), iat (issued at), and jti (JWT ID for revocation). Public and private claims carry application-specific data like role, email, and scope. The payload is Base64URL encoded, not encrypted — never put sensitive data like passwords in a JWT payload.

The Signature is computed as: Base64URL(Header) + "." + Base64URL(Payload) signed with the algorithm and key specified in the header. Verifying the signature proves that the token was issued by a trusted party and has not been tampered with.

JWT Verification Checklist

  • Verify the signature using the correct algorithm and key (fetch the JWKS endpoint to get current public keys)
  • Check exp — reject tokens where exp < current time
  • Check iss — the issuer must match your expected Authorization Server URL
  • Check aud — the audience must include your API's identifier
  • Check nbf — if present, the token must not be used before this time
  • Check algorithm in header matches expected algorithm (prevent algorithm confusion attacks)
// JWT verification — Node.js with jose library (recommended)
import { createRemoteJWKSet, jwtVerify } from 'jose';

// Fetch public keys from Authorization Server's JWKS endpoint
const JWKS = createRemoteJWKSet(
  new URL('https://accounts.google.com/.well-known/jwks.json')
);

async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://accounts.google.com',
    audience: 'https://api.yourapp.com',
    algorithms: ['RS256'],
  });

  // payload is verified — access claims safely
  return {
    userId: payload.sub,
    email: payload.email,
    scope: payload.scope,
    expiresAt: new Date(payload.exp * 1000),
  };
}

// Express.js middleware
async function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Bearer token required' });
  }
  try {
    const token = header.slice(7);
    req.user = await verifyAccessToken(token);
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token', details: err.message });
  }
}

// Python — JWT verification with PyJWT
"""
import jwt
from jwt import PyJWKClient

jwks_client = PyJWKClient("https://accounts.google.com/.well-known/jwks.json")

def verify_token(token: str) -> dict:
    signing_key = jwks_client.get_signing_key_from_jwt(token)
    payload = jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        audience="https://api.yourapp.com",
        issuer="https://accounts.google.com",
        options={"verify_exp": True}
    )
    return payload
"""

Implementation Examples: Node.js and Python

Node.js: Complete OAuth 2.0 Server with Passport.js

// Node.js / Express — OAuth 2.0 with Passport.js
// npm install passport passport-google-oauth20 express-session

const passport = require('passport');
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const session = require('express-session');

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 24 * 3600 * 1000 },
}));
app.use(passport.initialize());
app.use(passport.session());

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  callbackURL: 'https://yourapp.com/auth/google/callback',
  scope: ['openid', 'email', 'profile'],
}, async (accessToken, refreshToken, profile, done) => {
  // Find or create user in database
  let user = await db.users.findOne({ googleId: profile.id });
  if (!user) {
    user = await db.users.create({
      googleId: profile.id,
      email: profile.emails[0].value,
      name: profile.displayName,
      picture: profile.photos[0].value,
    });
  }
  // Store tokens if you need to call Google APIs on behalf of user
  await db.tokens.upsert({
    userId: user.id,
    accessToken,          // short-lived Google access token
    refreshToken,         // long-lived, only sent on first auth
  });
  return done(null, user);
}));

passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
  const user = await db.users.findById(id);
  done(null, user);
});

// Routes
app.get('/auth/google', passport.authenticate('google'));
app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login' }),
  (req, res) => res.redirect('/dashboard')
);
app.get('/auth/logout', (req, res) => {
  req.logout(() => res.redirect('/'));
});

// Protected route
app.get('/api/me', ensureAuthenticated, (req, res) => {
  res.json({ user: req.user });
});

function ensureAuthenticated(req, res, next) {
  if (req.isAuthenticated()) return next();
  res.status(401).json({ error: 'Not authenticated' });
}

Python: FastAPI + OAuth 2.0 (GitHub)

# Python FastAPI — GitHub OAuth 2.0
# pip install fastapi httpx python-dotenv

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
import httpx
import secrets
import os

app = FastAPI()
state_store = {}  # Use Redis in production

GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
REDIRECT_URI = "https://yourapp.com/auth/github/callback"

@app.get("/auth/github")
async def github_login():
    state = secrets.token_urlsafe(32)
    state_store[state] = True
    params = {
        "client_id": GITHUB_CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": "read:user user:email",
        "state": state,
    }
    url = "https://github.com/login/oauth/authorize?" + "&".join(
        f"{k}={v}" for k, v in params.items()
    )
    return RedirectResponse(url)

@app.get("/auth/github/callback")
async def github_callback(code: str, state: str):
    # Validate state to prevent CSRF
    if state not in state_store:
        raise HTTPException(status_code=400, detail="Invalid state parameter")
    del state_store[state]

    async with httpx.AsyncClient() as client:
        # Exchange code for access token
        token_res = await client.post(
            "https://github.com/login/oauth/access_token",
            json={
                "client_id": GITHUB_CLIENT_ID,
                "client_secret": GITHUB_CLIENT_SECRET,
                "code": code,
                "redirect_uri": REDIRECT_URI,
            },
            headers={"Accept": "application/json"},
        )
        token_data = token_res.json()
        access_token = token_data["access_token"]

        # Fetch user profile
        user_res = await client.get(
            "https://api.github.com/user",
            headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
        )
        user = user_res.json()

    # Create session or JWT for your app
    return {"user_id": user["id"], "login": user["login"], "email": user["email"]}

OAuth 2.0 Security Best Practices

Always Use PKCE for Public Clients

Any client that cannot securely store a client_secret must use PKCE. This includes all browser-based apps and all mobile/desktop apps. PKCE prevents authorization code interception attacks where an attacker intercepts the callback URL and steals the authorization code. Without PKCE, a stolen authorization code can be exchanged for tokens.

Validate the State Parameter

The state parameter is your primary CSRF defense in OAuth flows. Generate a cryptographically random value, store it in session before redirecting, and verify it matches exactly when the callback arrives. Reject any callback where the state is missing or does not match. Failure to validate state exposes your users to CSRF attacks where an attacker can link their own OAuth code to a victim's account.

Token Storage Strategy

For server-side apps, store tokens in the server-side session (Redis-backed), never in the client. For SPAs, use memory storage for access tokens and httpOnly cookies for refresh tokens. Never store tokens in localStorage — XSS attacks can steal them. If you must use localStorage (e.g., for offline support), treat it as a cache and never store refresh tokens there.

Short-Lived Access Tokens + Refresh Token Rotation

Set access tokens to expire in 15-60 minutes. Use refresh token rotation so that every refresh request issues a new refresh token and invalidates the previous one. If a refresh token is used twice (by an attacker who stole it), the server detects the reuse and invalidates the entire token family, protecting the user.

Strict Redirect URI Validation

Register exact redirect URIs (not wildcard patterns) with your Authorization Server. The server should reject any authorization request whose redirect_uri does not exactly match a registered URI. Open redirect vulnerabilities in the redirect_uri allow attackers to intercept authorization codes. Never allow redirect_uri values that contain user-supplied data.

// Security checklist implementation

// 1. Validate redirect_uri strictly (server-side)
const ALLOWED_REDIRECT_URIS = [
  'https://yourapp.com/auth/callback',
  'https://staging.yourapp.com/auth/callback',
];

function validateRedirectUri(uri) {
  return ALLOWED_REDIRECT_URIS.includes(uri);
}

// 2. Validate state parameter
function generateState() {
  return Buffer.from(crypto.randomBytes(32)).toString('base64url');
}

function validateState(received, stored) {
  if (!received || !stored) return false;
  // Use constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(received),
    Buffer.from(stored)
  );
}

// 3. Token introspection — verify opaque tokens
async function introspectToken(token) {
  const res = await fetch('https://auth.example.com/oauth/introspect', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Authorization': 'Basic ' + Buffer.from(
        process.env.CLIENT_ID + ':' + process.env.CLIENT_SECRET
      ).toString('base64'),
    },
    body: new URLSearchParams({ token }),
  });
  const data = await res.json();
  if (!data.active) throw new Error('Token is not active');
  return data;
}

// 4. Revoke tokens on logout
async function revokeToken(token, tokenTypeHint = 'access_token') {
  await fetch('https://auth.example.com/oauth/revoke', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      token,
      token_type_hint: tokenTypeHint,
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
    }),
  });
}

Social Login: Google, GitHub, and Facebook OAuth

Social login (Sign in with Google/GitHub/Facebook) uses OAuth 2.0 under the hood. Each provider has slightly different scopes, endpoints, and token behaviors. Here is a comparison of the three most popular providers.

Google OAuth 2.0 / OIDCOAuth 2.0 + OIDC

Google implements both OAuth 2.0 and OpenID Connect. The authorization endpoint is https://accounts.google.com/o/oauth2/v2/auth. Use the openid scope to get an ID token, email for the user's email, and profile for name and picture. Google's tokens can be verified against the public keys at https://www.googleapis.com/oauth2/v3/certs.

GitHub OAuth 2.0OAuth 2.0 only

GitHub uses classic OAuth 2.0 (not OIDC — there is no ID token). Authorization endpoint: https://github.com/login/oauth/authorize. Token endpoint: https://github.com/login/oauth/access_token. Use scopes read:user and user:email to get the user's profile and email. GitHub access tokens do not expire unless revoked, so you must handle token revocation explicitly.

Facebook OAuth 2.0OAuth 2.0

Facebook Graph API uses OAuth 2.0. Authorization endpoint: https://www.facebook.com/v18.0/dialog/oauth. Request permissions (scopes) like email and public_profile. Facebook access tokens can be short-lived (1-2 hours) with the option to exchange for a long-lived token (60 days) using the client secret. Use the /me endpoint with the access token to fetch user data.

// Unified social login handler — Node.js
// Works with Google, GitHub, Facebook

const PROVIDERS = {
  google: {
    authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
    tokenUrl: 'https://oauth2.googleapis.com/token',
    userUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
    scope: 'openid email profile',
    clientId: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  },
  github: {
    authUrl: 'https://github.com/login/oauth/authorize',
    tokenUrl: 'https://github.com/login/oauth/access_token',
    userUrl: 'https://api.github.com/user',
    scope: 'read:user user:email',
    clientId: process.env.GITHUB_CLIENT_ID,
    clientSecret: process.env.GITHUB_CLIENT_SECRET,
  },
  facebook: {
    authUrl: 'https://www.facebook.com/v18.0/dialog/oauth',
    tokenUrl: 'https://graph.facebook.com/v18.0/oauth/access_token',
    userUrl: 'https://graph.facebook.com/me?fields=id,name,email,picture',
    scope: 'email,public_profile',
    clientId: process.env.FACEBOOK_APP_ID,
    clientSecret: process.env.FACEBOOK_APP_SECRET,
  },
};

app.get('/auth/:provider', (req, res) => {
  const provider = PROVIDERS[req.params.provider];
  if (!provider) return res.status(404).json({ error: 'Unknown provider' });

  const state = generateState();
  req.session.oauthState = state;
  req.session.oauthProvider = req.params.provider;

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: provider.clientId,
    redirect_uri: 'https://yourapp.com/auth/callback',
    scope: provider.scope,
    state,
  });
  res.redirect(provider.authUrl + '?' + params);
});

app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;
  const providerName = req.session.oauthProvider;
  const provider = PROVIDERS[providerName];

  if (!validateState(state, req.session.oauthState)) {
    return res.status(403).json({ error: 'Invalid state' });
  }

  // Exchange code for token
  const tokenRes = await fetch(provider.tokenUrl, {
    method: 'POST',
    headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: provider.clientId,
      client_secret: provider.clientSecret,
      code,
      redirect_uri: 'https://yourapp.com/auth/callback',
    }),
  });
  const { access_token } = await tokenRes.json();

  // Fetch user profile
  const userRes = await fetch(provider.userUrl, {
    headers: { Authorization: 'Bearer ' + access_token },
  });
  const profile = await userRes.json();

  // Upsert user in your database
  const user = await upsertUser(providerName, profile);
  req.session.userId = user.id;
  res.redirect('/dashboard');
});

OpenID Connect (OIDC) Explained

OpenID Connect is an identity layer built directly on top of OAuth 2.0. It adds a standardized way to authenticate users and retrieve their profile information. While OAuth 2.0 answers "Can this app access this resource?", OIDC answers "Who is this user?"

OIDC introduces three key additions to OAuth 2.0: the ID token (a JWT containing user identity claims), the UserInfo endpoint (for fetching additional claims beyond what fits in the ID token), and a standardized discovery document (.well-known/openid-configuration) that describes the provider's endpoints and capabilities.

Standard OIDC Claims

OIDC defines a set of standard claims in the ID token payload. The sub claim is the unique user identifier (do not use email as a user identifier — it can change). The name, given_name, and family_name claims provide the user's display name. The email and email_verified claims give the user's email and whether it has been verified. The picture claim is a URL to the user's profile photo.

{ "sub": "10769150350006150715113082367", "iss": "https://accounts.google.com", "aud": "your-client-id.apps.googleusercontent.com", "exp": 1735689600, "iat": 1735686000, "email": "alice@example.com", "email_verified": true, "name": "Alice Smith", "picture": "https://lh3.googleusercontent.com/...", "nonce": "abc123" }

OIDC Authorization Code Flow

// OpenID Connect — Complete Implementation
// Using the openid-client library (recommended)
// npm install openid-client

import { Issuer, generators } from 'openid-client';

// Discovery — fetches .well-known/openid-configuration automatically
const googleIssuer = await Issuer.discover('https://accounts.google.com');

const client = new googleIssuer.Client({
  client_id: process.env.GOOGLE_CLIENT_ID,
  client_secret: process.env.GOOGLE_CLIENT_SECRET,
  redirect_uris: ['https://yourapp.com/auth/callback'],
  response_types: ['code'],
});

// Generate auth URL with PKCE
app.get('/auth/login', (req, res) => {
  const codeVerifier = generators.codeVerifier();
  const codeChallenge = generators.codeChallenge(codeVerifier);
  const state = generators.state();
  const nonce = generators.nonce(); // OIDC-specific replay protection

  req.session.codeVerifier = codeVerifier;
  req.session.state = state;
  req.session.nonce = nonce;

  const url = client.authorizationUrl({
    scope: 'openid email profile',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state,
    nonce,
  });
  res.redirect(url);
});

// Handle callback
app.get('/auth/callback', async (req, res) => {
  const params = client.callbackParams(req);

  // Exchange code for tokens — validates state, nonce, and token claims
  const tokenSet = await client.callback(
    'https://yourapp.com/auth/callback',
    params,
    {
      code_verifier: req.session.codeVerifier,
      state: req.session.state,
      nonce: req.session.nonce,
    }
  );

  // tokenSet.access_token — use to call APIs
  // tokenSet.id_token — contains user identity
  // tokenSet.claims() — parsed ID token payload

  const claims = tokenSet.claims();
  // claims.sub — unique user ID (stable, use as primary key)
  // claims.email — user email
  // claims.name — display name
  // claims.picture — avatar URL
  // claims.email_verified — boolean

  const user = await upsertUser({
    oidcSub: claims.sub,
    email: claims.email,
    name: claims.name,
    picture: claims.picture,
  });

  req.session.userId = user.id;
  res.redirect('/dashboard');
});

OAuth 2.0 vs SAML vs JWT: Which to Choose?

Three protocols dominate enterprise identity and API security. Understanding when to use each saves significant architectural pain.

ProtocolToken FormatComplexityIdentityBest For
OAuth 2.0Opaque / JWTMediumNoAPI authorization, 3rd-party access
OAuth 2.0 + OIDCJWT (ID token)MediumYesSocial login, modern SSO
SAML 2.0XML AssertionHighYesEnterprise SSO, Active Directory
JWT (standalone)JWTLowOptionalStateless sessions, microservices
SAML 2.0

SAML 2.0 (Security Assertion Markup Language) is an XML-based protocol designed for enterprise Single Sign-On (SSO). It is widely supported by enterprise IdPs (Identity Providers) like Active Directory Federation Services, Okta, and OneLogin. SAML assertions are larger than JWTs, XML-based, and often signed with XML-DSig. SAML is the right choice when your customers are enterprises with existing Active Directory/LDAP infrastructure and you need to support SSO to multiple services under one corporate identity.

JWT (standalone)

JWT by itself is not an authentication protocol — it is a token format. JWTs can be used as OAuth 2.0 access tokens, OIDC ID tokens, or standalone stateless session tokens. The confusion arises because many systems use JWTs for authentication without implementing OAuth 2.0. This is fine for internal systems but lacks the standardized delegation, scopes, and token revocation that OAuth 2.0 provides.

OAuth 2.0

OAuth 2.0 is the right choice for API authorization, third-party integrations, and building identity platforms (when combined with OIDC). It is the most flexible of the three and is supported by every major identity provider.

Common OAuth Vulnerabilities and Prevention

CSRF in OAuth Callback

An attacker initiates an OAuth flow, gets a valid authorization code, then tricks a victim into clicking the callback URL with the attacker's code. If the victim's session is used to exchange the code, the victim's account becomes linked to the attacker's identity. Prevention: always validate the state parameter. The state value must match exactly what was stored in the user's session before the redirect.

Open Redirect via redirect_uri

If the Authorization Server does not strictly validate the redirect_uri, an attacker can supply a malicious URI and steal the authorization code when it is redirected there. Prevention: register exact URIs (no wildcards), validate strictly server-side, and never accept redirect_uri values containing user-controlled data.

Authorization Code / Token Leakage via Referrer

If the callback page loads third-party resources (analytics, ads, fonts), the authorization code in the URL may leak via the Referrer header. Prevention: use the Referrer-Policy: no-referrer header, process and remove the code from the URL immediately after extraction, and use PKCE so a stolen code cannot be exchanged without the code_verifier.

Confused Deputy / Audience Confusion

An attacker tricks a service into accepting a token issued for a different audience. For example, using a Google ID token issued for app A to authenticate to app B. Prevention: always verify the aud (audience) claim matches your own API identifier. Never accept tokens without audience validation.

Token Substitution

An attacker swaps a token from one context to another. For example, using an access token issued for read scope to call a write endpoint if the endpoint does not check scopes. Prevention: validate scopes on every protected endpoint, not just at the gateway.

// Scope validation middleware — Express.js
function requireScope(...requiredScopes) {
  return (req, res, next) => {
    const tokenScopes = (req.user.scope || '').split(' ');
    const hasAllScopes = requiredScopes.every(s => tokenScopes.includes(s));
    if (!hasAllScopes) {
      return res.status(403).json({
        error: 'insufficient_scope',
        required: requiredScopes,
        granted: tokenScopes,
      });
    }
    next();
  };
}

// Usage
app.get('/api/reports', requireAuth, requireScope('read:reports'), getReports);
app.post('/api/data', requireAuth, requireScope('write:data'), createData);
app.delete('/api/data/:id', requireAuth, requireScope('write:data', 'admin'), deleteData);

// Audience validation in token verification
async function verifyTokenStrict(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: process.env.EXPECTED_ISSUER,
    audience: process.env.API_IDENTIFIER, // e.g. "https://api.yourapp.com"
    algorithms: ['RS256'],
    clockTolerance: 30, // 30 second clock skew allowance
  });
  return payload;
}

Try our related security tools

FAQ

What is the difference between OAuth 2.0 and OpenID Connect?

OAuth 2.0 is an authorization framework that lets applications request access to resources on behalf of a user. It does not define how to authenticate users or exchange user identity information. OpenID Connect (OIDC) is a thin identity layer built on top of OAuth 2.0 that adds user authentication. OIDC introduces the ID token (a JWT with identity claims like name, email, and profile picture) and a standardized UserInfo endpoint. Use OAuth 2.0 alone when you only need resource access; use OIDC when you need to know who the user is.

Should I use the Implicit flow or Authorization Code + PKCE for my SPA?

Always use Authorization Code + PKCE for SPAs. The Implicit flow (where the access token is returned directly in the URL fragment) is deprecated as of OAuth 2.0 Security Best Current Practice (RFC 9700). The Implicit flow exposes tokens in browser history and referrer headers. PKCE solves the same problem (no client_secret needed for public clients) without the security issues.

Where should I store OAuth tokens in a browser?

The safest approach is to store refresh tokens in httpOnly, Secure, SameSite=Strict cookies (inaccessible to JavaScript, thus protected from XSS) and keep access tokens only in memory (a JavaScript variable or closure). Never store refresh tokens in localStorage — any XSS vulnerability on your page can steal them. If you use cookie storage, implement CSRF protection with the SameSite attribute and CSRF tokens.

How do refresh tokens and access token rotation work?

Access tokens are short-lived (15 minutes to 1 hour). When they expire, the client uses its refresh token to request a new access token from the Authorization Server. With refresh token rotation, each refresh request issues a new refresh token and invalidates the previous one. If a stolen refresh token is used after the legitimate user has already used it, the server detects the reuse (the old token is already invalidated) and revokes the entire token family, forcing re-authentication. This limits the damage window of a stolen refresh token.

What is PKCE and why is it necessary?

PKCE (Proof Key for Code Exchange) is an extension that prevents authorization code interception attacks for public clients (SPAs, mobile apps) that cannot securely store a client_secret. The client generates a random code_verifier, hashes it to create a code_challenge, and sends the challenge with the authorization request. When exchanging the code for tokens, the client sends the original verifier. The server hashes it and checks it matches the stored challenge. An attacker who intercepts the authorization code cannot exchange it without the original code_verifier.

What is the difference between OAuth 2.0 scopes and roles?

Scopes in OAuth 2.0 represent the permissions a client application is requesting on behalf of a user (e.g., read:contacts, write:calendar). They are coarse-grained and defined by the resource server. Roles (RBAC — Role-Based Access Control) are typically user-level attributes (admin, editor, viewer) that determine what a user can do in your system. Both are often used together: OAuth scopes limit what the application can do, while roles (embedded as claims in the access token) determine what the user is allowed to do within those scopes.

How do I revoke an OAuth access token?

For JWT access tokens, true revocation is difficult because they are self-contained and valid until expiry. Common approaches include: maintaining a token revocation list (blocklist) checked on each request (fast with Redis), using short-lived access tokens (15 min) so stolen tokens expire quickly, and calling the Authorization Server's revocation endpoint (RFC 7009) for refresh tokens. For opaque tokens, call the introspection endpoint on each request — the Authorization Server tracks active tokens and returns active: false for revoked ones.

Can OAuth 2.0 replace a traditional username/password login?

OAuth 2.0 + OpenID Connect can fully replace traditional login. Users authenticate with a trusted identity provider (Google, GitHub, your own OIDC server) and your application receives verified identity claims. You never store passwords. This improves security (no password database to breach) and UX (no registration forms). However, you add a dependency on the identity provider. For maximum resilience, support both social login and a local password option, or use an identity service like Auth0 or Supabase Auth that handles both.

𝕏 Twitterin LinkedIn
Czy to było pomocne?

Bądź na bieżąco

Otrzymuj cotygodniowe porady i nowe narzędzia.

Bez spamu. Zrezygnuj kiedy chcesz.

Try These Related Tools

JWTJWT DecoderB→Base64 Encoder🔐HMAC Generator

Related Articles

API Design Guide: REST Best Practices, OpenAPI, Auth, Pagination, and Caching

Master API design. Covers REST principles, versioning strategies, JWT/OAuth 2.0 authentication, OpenAPI/Swagger specification, rate limiting, RFC 7807 error handling, pagination patterns, ETags caching, and REST vs GraphQL vs gRPC vs tRPC comparison.

NestJS Complete Guide: Modules, Controllers, Services, DI, TypeORM, JWT Auth, and Testing

Master NestJS from scratch. Covers modules, controllers, services, providers, dependency injection, TypeORM/Prisma database integration, JWT authentication, Guards, Pipes, Interceptors, Exception Filters, and unit/e2e testing with Jest.

Web Security Guide: OWASP Top 10, XSS, CSRF, SQL Injection, and Best Practices

Master web security with this complete guide. Covers OWASP Top 10, XSS, CSRF, SQL injection, authentication security, JWT, HTTPS, security headers, CORS, rate limiting, and vulnerability testing.