Skip to main content

OAuth 2.0 and OpenID Connect: The Complete Guide

Published: February 21, 2026 Updated: May 8, 2026 Larry Qu 27 min read

OAuth 2.0 and OpenID Connect (OIDC) are the backbone of modern authentication and authorization. Whether you’re building a web app, mobile app, or API, understanding these protocols is essential. Nearly every major platform you integrate with—Google, GitHub, Apple, Microsoft Entra ID—exposes its identity layer through these same standards. Once you learn the mental model, you can apply it to any provider with only minor adjustments.

In this guide, we’ll explore OAuth 2.0 flows, OIDC, tokens, and security best practices. We’ll also look at the decision process that leads you to choose one flow over another, and the common mistakes that production teams make when they deploy these protocols. Each section builds on the previous one, so the later code samples are complete versions of the patterns you have already met in isolation. By the end, you should be able to reason about the security of an OAuth integration rather than merely copying a provider’s quick-start snippet.

Understanding OAuth 2.0

Before OAuth, delegated access was built on a dangerously simple pattern: applications asked users for their passwords and then acted on the user’s behalf with those credentials. This password-sharing model created a cascade of security problems. Every application that stored a password inherited the full power of the account, so a breach in any single app could compromise the user’s identity everywhere. There was no concept of least privilege—an app that only needed to read profile pictures was granted the entire account, and users could not revoke a specific app’s access without changing their password and breaking every other integration. If a password leaked, the damage was total, and the user had no recourse short of rotating the credential in every system that used it. OAuth 2.0 was designed to eliminate this pattern by replacing shared passwords with short-lived, scoped access tokens that the user grants and revokes on their own terms. The protocol is a framework rather than a single flow: it defines roles, endpoints, and a family of grant types, and leaves room for providers to extend it. This flexibility is why OAuth 2.0 has survived a decade of attacks and is now the industry default for third-party authorization. Understanding why the design is the way it is will help you debug the provider-specific quirks you will inevitably hit in production.

The central idea is to separate authentication, proving who the user is, from authorization, deciding what an application is allowed to do. Instead of surrendering credentials, the user is redirected to an authorization server they trust, approves a set of permissions called scopes, and the server issues a token that the application presents to the resource server. The user’s password never touches the third-party application. The token is the unit of trust, and its properties—what it allows, how long it lives, and who can read it—are the subject of most of this guide. This guide walks through the protocol’s roles, its grant types, and the security considerations you must get right in production, from the authorization code flow to OIDC’s identity layer and the PKCE extension that protects public clients. Where the protocol gives you choices, we point out the trade-offs so you can make an informed decision rather than following a default configuration blindly.

The Authorization Problem

The diagrams below contrast the two worlds. The top half shows the pre-OAuth state of affairs: the application asks for the password and, once granted, sees everything and can be revoked by nothing short of changing the password. The bottom half shows the OAuth model, where a dedicated authorization server mediates consent and hands out tokens that are scoped, expiring, and individually revocable. Notice that every problem listed on the top diagram stems from one root cause: the resource server has no way to distinguish the application from the user, because it only ever sees the user’s password. OAuth’s answer is to introduce an intermediary that the user trusts and that the resource server can validate against. Keep this before/after picture in mind—every mechanism discussed later, from scopes to refresh token rotation, exists to preserve the properties shown in the lower diagram.

┌─────────────────────────────────────────────────────────────┐
│              Before OAuth: The Problem                        │
│                                                             │
│   User                                                     │
│    │                                                       │
│    ▼                                                       │
│   ┌──────────┐    Password    ┌──────────┐                 │
│   │  My App  │ ───────────►  │  Photo   │                 │
│   │          │                │   Site   │                 │
│   └──────────┘                └──────────┘                 │
│        │                                                    │
│        │ "Give me your password so I can                   │
│        │  access your photos"                              │
│        ▼                                                    │
│   ❌ App sees all user credentials!                         │
│   ❌ App gets full access to account                        │
│   ❌ User can't revoke app access                           │
│   ❌ Compromise = full account compromise                   │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│              After OAuth: The Solution                        │
│                                                             │
│   User                                                     │
│    │                                                       │
│    ▼                                                       │
│   ┌──────────┐                 ┌──────────┐                 │
│   │  My App  │ ── Token ───► │  Photo   │                 │
│   │          │                │   Site   │                 │
│   └──────────┘                └──────────┘                 │
│        │                                                    │
│        ▼                                                    │
│   ┌──────────┐    "Authorize"  ┌──────────┐                 │
│   │   Auth   │ ◄────────────── │  User    │                 │
│   │  Server  │                │ Browser  │                 │
│   └──────────┘                └──────────┘                 │
│                                                             │
│   ✅ App never sees password                                │
│   ✅ Limited access (scopes)                                │
│   ✅ User can revoke access                                 │
│   ✅ Tokens can expire                                      │
└─────────────────────────────────────────────────────────────┘

The two diagrams above capture the fundamental shift OAuth introduced. Before OAuth, the application received the user’s password and gained unfettered access; afterward, the application negotiates a token with the authorization server while the resource server validates that token on every request. Notice that the “After” diagram introduces a third party—the authorization server—which becomes the single place where the user’s consent is recorded and managed. This separation is what makes revocation, expiry, and fine-grained scoping possible. Because consent is recorded centrally, the user can review and revoke individual applications without affecting others, something that was simply impossible with the password-sharing model.

OAuth 2.0 Roles

Every OAuth flow involves four participants, and it is worth understanding their responsibilities precisely because the terminology is reused across every provider you will integrate with. The resource owner is the user who controls the data; the client is the application requesting access on the user’s behalf; the authorization server issues tokens after authenticating the resource owner; and the resource server hosts the protected data and validates tokens before serving them. A single system can play multiple roles—for example, Google acts as both an authorization server and a resource server for its own APIs. The distinction between the client and the resource server is worth keeping straight because it explains which endpoint validates what: the authorization server talks to the client during the grant, while the resource server validates tokens on every API call. The Python dictionary below summarizes each role with a concrete example so you can map these abstractions onto the systems you will actually build.

oauth_roles = {
    "resource_owner": {
        "description": "The user",
        "example": "You, granting access to your photos"
    },
    
    "client": {
        "description": "The application requesting access",
        "example": "My Photo Printing App"
    },
    
    "authorization_server": {
        "description": "The server that authenticates and issues tokens",
        "example": "Google, Auth0, Okta"
    },
    
    "resource_server": {
        "description": "The API that hosts protected resources",
        "example": "Google Photos API"
    }
}

These four roles form the vocabulary of every OAuth exchange. The critical insight is that the client and the resource owner are distinct entities: the client acts on the owner’s behalf but never impersonates them. How the client obtains a token, and how that token is delivered, depends entirely on the context of the application—which is precisely what the grant types address. An application that stores secrets server-side can be trusted differently from one that ships its code to every visitor’s browser. This is why the same protocol defines several different ways to obtain a token rather than a single universal method.

OAuth 2.0 Grant Types

OAuth 2.0 defines several grant types because no single token-exchange pattern fits every kind of application. A server-side web app can securely store a client secret and keep tokens out of the browser, whereas a single-page app or mobile app running on an untrusted device cannot. The authorization code flow is the recommended choice for most web and mobile applications because it keeps tokens on the server and supports the PKCE extension for public clients. The implicit flow, which once returned tokens directly in the redirect URL, is deprecated for good reason: tokens exposed in the browser are easy to steal. Client credentials is the pattern for machine-to-machine communication where no user is involved, and the device code flow handles smart TVs and other devices without a convenient browser. Finally, refresh tokens let a client obtain new access tokens without forcing the user to authenticate again. Choosing the right grant type is the first major design decision in any integration, and it is usually dictated by the kind of client you are building. The YAML below outlines these grant types and the circumstances under which each should be used.

# 1. Authorization Code Flow (for web apps)
# Most secure, uses server-side token exchange

flow_authorization_code:
  steps:
    - "User clicks 'Login with Google'"
    - "Redirect to Google OAuth"
    - "User authorizes"
    - "Redirect back with auth code"
    - "Server exchanges code for token"
    
  use_when:
    - "Server-side web apps"
    - "Mobile apps (with PKCE)"

# 2. Implicit Flow (deprecated)
# Don't use - security issues

# 3. Client Credentials Flow
# Server-to-server communication

flow_client_credentials:
  use_when:
    - "Machine-to-machine"
    - "Background jobs"
    
  example:
    "Service A accessing Service B"

# 4. Device Code Flow
# For devices without browsers

# 5. Refresh Token Flow
# Get new access tokens

The authorization code flow is the workhorse of modern OAuth, used by virtually every major provider including Google, GitHub, and Auth0. Its defining characteristic is that the access token is never exposed to the browser: the client first receives a single-use authorization code, then exchanges that code for tokens in a direct server-to-server request. Because the exchange requires the client secret and happens outside the browser, an attacker who intercepts the redirect cannot obtain tokens on their own. The flow also supports CSRF protection through a state parameter and, with PKCE, defends against authorization code interception attacks. This design reflects a clear trade-off: slightly more round trips in exchange for a dramatically smaller attack surface.

Authorization Code Flow

The full flow involves a sequence of carefully ordered steps, each with a specific security purpose. The client begins by constructing an authorization URL that identifies itself, states the scopes it needs, and includes a randomly generated state value. After the user approves, the authorization server redirects back with a code, and the client exchanges that code—along with its secret—for an access token. When the token expires, the client can use a refresh token to obtain a new one silently. The ordering matters: the code is single-use, short-lived, and exchanged immediately, so a stolen code becomes worthless almost at once. The Python class below models these three phases as methods, giving you a clean reference implementation of the protocol.

Step-by-Step

The class implements the three most important operations of the flow. get_authorization_url builds the redirect URL with the required parameters, most notably the state parameter that must be validated on the callback to prevent cross-site request forgery. exchange_code_for_token performs the server-side token exchange, sending the client secret over a direct connection that browsers never see. refresh_token handles the silent renewal path that keeps long-lived sessions alive. Notice how the scopes are requested up front and how every parameter is passed explicitly—omitting any of them is a common source of integration failures. The class is deliberately free of framework dependencies so you can see the protocol clearly before we introduce an HTTP layer.

# Authorization Code Flow

class AuthorizationCodeFlow:
    """Web app OAuth flow"""
    
    def __init__(self, client_id, client_secret, redirect_uri):
        self.client_id = client_id
        self.client_secret = client_secret
        self.redirect_uri = redirect_uri
    
    def get_authorization_url(self, state, scope):
        """Step 1: Get URL to redirect user"""
        params = {
            "response_type": "code",
            "client_id": self.client_id,
            "redirect_uri": self.redirect_uri,
            "scope": scope,
            "state": state  # CSRF protection
        }
        
        # Example: https://auth.example.com/authorize?...
        return f"https://auth.example.com/authorize?" \
               f"{urllib.parse.urlencode(params)}"
    
    def exchange_code_for_token(self, code):
        """Step 2: Exchange code for token"""
        response = requests.post(
            "https://auth.example.com/token",
            data={
                "grant_type": "authorization_code",
                "code": code,
                "client_id": self.client_id,
                "client_secret": self.client_secret,
                "redirect_uri": self.redirect_uri
            }
        )
        
        return response.json()
    
    def refresh_token(self, refresh_token):
        """Step 3: Get new access token"""
        response = requests.post(
            "https://auth.example.com/token",
            data={
                "grant_type": "refresh_token",
                "refresh_token": refresh_token,
                "client_id": self.client_id,
                "client_secret": self.client_secret
            }
        )
        
        return response.json()

The abstract class above shows the protocol, but a real application needs to wire these calls into an HTTP framework and manage session state. The implementation below uses Flask to demonstrate the pattern end to end. The /login route generates a fresh state value, stores it in the user’s session, and redirects to the provider. The /callback route then validates that the returned state matches what we stored—rejecting the request if it does not—before exchanging the code for tokens and fetching the user’s profile from the userinfo endpoint. The session is used to persist the state value between two stateless HTTP requests, which is a detail that trips up many first-time implementers.

Implementation Example

A few production details in this example are easy to overlook. The state value must be cryptographically random and tied to the session so that an attacker cannot forge a callback. The token response should be validated for the expected fields, and the userinfo lookup should send the returned access token in an Authorization header. Finally, note that the application builds its own authenticated session only after confirming the provider’s response is legitimate—never trust the redirect parameters themselves. This last point is important: the user could be tricked into visiting a callback URL that was not generated by your login flow, so every piece of data arriving on the redirect must be treated as untrusted input.

# Flask implementation
from flask import Flask, request, session, redirect
import requests

app = Flask(__name__)
app.secret_key = "your-secret-key"

# Configuration
AUTH_SERVER = "https://accounts.google.com"
CLIENT_ID = "your-client-id"
CLIENT_SECRET = "your-client-secret"
REDIRECT_URI = "https://yourapp.com/callback"

@app.route("/login")
def login():
    state = secrets.token_hex(16)
    session["oauth_state"] = state
    
    auth_url = (
        f"{AUTH_SERVER}/o/oauth2/v2/auth?"
        f"client_id={CLIENT_ID}&"
        f"redirect_uri={REDIRECT_URI}&"
        f"response_type=code&"
        f"scope=openid%20email%20profile&"
        f"state={state}"
    )
    
    return redirect(auth_url)

@app.route("/callback")
def callback():
    # Verify state
    if request.args.get("state") != session.get("oauth_state"):
        return "Invalid state", 400
    
    # Exchange code for token
    token_response = requests.post(
        f"{AUTH_SERVER}/token",
        data={
            "code": request.args.get("code"),
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
            "redirect_uri": REDIRECT_URI,
            "grant_type": "authorization_code"
        }
    ).json()
    
    # Get user info
    user_info = requests.get(
        f"{AUTH_SERVER}/userinfo",
        headers={"Authorization": f"Bearer {token_response['access_token']}"}
    ).json()
    
    # Create session
    session["user"] = user_info
    session["access_token"] = token_response["access_token"]
    
    return redirect("/dashboard")

The flow above gets an application authorized to act on a user’s behalf, but it does not tell the application who the user actually is. OAuth 2.0 is an authorization framework—it answers “what can this app access”—and deliberately leaves authentication unspecified. Different providers return different user identifiers, and client-side validation of identity is undefined. OpenID Connect solves this by layering an identity protocol on top of OAuth 2.0, standardizing how clients verify a user’s identity and retrieve their profile claims. The distinction matters because authorization without authentication gives you access to data, but it does not give you a reliable identity you can store, display, or base security decisions on.

OpenID Connect (OIDC)

OIDC builds on the same machinery you have already seen: the authorization code flow, scopes, and token endpoints all remain. What OIDC adds is the ID token, a signed JWT that carries identity claims about the user, plus a set of standard scopes (openid, profile, email) and a userinfo endpoint for retrieving profile data. Perhaps most importantly, OIDC defines a discovery mechanism so clients can learn a provider’s configuration automatically rather than hardcoding endpoints. Because the ID token is a signed JWT, the client can verify the user’s identity locally without a second network call to the provider. The diagram below shows how OIDC sits on top of OAuth 2.0, turning a protocol that only authorizes into one that also authenticates.

OIDC Overview

The key mental model is that OIDC is a thin identity layer above OAuth. Everything you already know about the authorization code flow still applies; the difference is the addition of standard scopes, the ID token, and the userinfo endpoint. When a client sends the openid scope, the authorization server commits to issuing an ID token and following OIDC’s discovery and validation rules. This is why you will frequently hear OIDC described as “OAuth 2.0 plus authentication”—the underlying token exchange is unchanged. If you understand the authorization code flow, you already understand ninety percent of how OIDC works.

┌─────────────────────────────────────────────────────────────┐
│              OpenID Connect Layer                            │
│                                                             │
│   OAuth 2.0: Authorization                                  │
│   ┌───────────────────────────────────────────────────┐    │
│   │  + UserInfo endpoint                              │    │
│   │  + ID Token (JWT)                                 │    │
│   │  + Standard scopes (openid, profile, email)       │    │
│   │  + Discovery protocol                            │    │
│   └───────────────────────────────────────────────────┘    │
│                                                             │
│   = Authentication + Authorization                          │
└─────────────────────────────────────────────────────────────┘

With OIDC in the picture, applications now receive multiple tokens from the same flow, and it is critical to keep their purposes distinct. The access token authorizes API calls and is intended for the resource server; the ID token authenticates the user and is intended for the client application. Confusing the two is one of the most common integration mistakes, and the consequences are subtle: an ID token sent as an access token is usually rejected by the API, while an access token used as an identity proof tells you nothing reliable about the user. The comparison below contrasts them across the dimensions that matter for validation and storage.

ID Token vs Access Token

The audience field is the key differentiator. An ID token is meant for your application, so you validate it with your own client ID as the expected audience. An access token is meant for the API, so your application typically passes it along without inspecting its contents—the resource server performs its own validation, often through introspection. Because the two tokens serve different parties, they also have different lifetimes and storage requirements, which we cover in the token security section. The rule of thumb is simple: read the ID token, forward the access token, and keep the refresh token secret.

token_comparison = {
    "id_token": {
        "purpose": "Authentication (who is the user)",
        "audience": "Client application",
        "format": "JWT",
        "contains": "User claims (sub, name, email)",
        "verified_by": "Signature + claims"
    },
    
    "access_token": {
        "purpose": "Authorization (what can they access)",
        "audience": "Resource server (API)",
        "format": "Opaque or JWT",
        "contains": "Scopes, user, client info",
        "verified_by": "Token introspection"
    }
}

When you decode an ID token, the claims it contains answer two questions: who issued this token, and who is the user. The issuer and audience claims anchor the token to a specific provider and a specific client, so a token issued to a different application cannot be replayed against yours. The subject claim is the provider’s stable identifier for the user—treat it as the primary key for the user in your own database, and never use the email address as a key because email addresses can change. The timestamps matter just as much as the identity claims: the iat and exp claims bound when the token is valid, and a token checked outside that window must be rejected.

ID Token Claims

The decoded ID token below shows the standard claims plus the profile claims that come from the profile and email scopes. Always validate the issuer, audience, signature, and expiration before trusting any of this data, and never make authorization decisions based on claims you have not verified. The at_hash claim, when present, binds the ID token to the access token issued alongside it, preventing an attacker from swapping tokens between sessions. Notice that even so-called profile claims like name and picture are optional and provider-specific, which is why applications should never assume their exact shape.

# Example ID Token (decoded)

id_token = {
    "iss": "https://accounts.google.com",  # Issuer
    "azp": "client-id.apps.googleusercontent.com",  # Authorized party
    "aud": "client-id.apps.googleusercontent.com",  # Audience
    "sub": "1234567890",  # Subject (user ID)
    "at_hash": "access_token_hash",  # Access token hash
    "iat": 1516239022,  # Issued at
    "exp": 1516242622,  # Expiration
    
    # Custom claims
    "name": "John Doe",
    "picture": "https://...",
    "email": "[email protected]",
    "email_verified": True,
    "locale": "en"
}

Hardcoding endpoints works when you integrate with one provider, but it becomes a maintenance burden as you add more. OIDC Discovery solves this with a well-known document published at a predictable URL. Any OIDC-compliant provider publishes its configuration at /.well-known/openid-configuration, listing the authorization, token, userinfo, and JWKS endpoints along with the response types and signing algorithms it supports. A client can fetch this document once, cache it, and derive every URL it needs from the provider’s issuer identifier. This is also how libraries and SDKs stay generic: they read the discovery document and adapt to whatever provider you point them at.

OIDC Discovery

The discovery document below is representative of what Google publishes. Notice that it declares which signing algorithms are supported—most providers use RS256—so clients know which keys to fetch from the JWKS endpoint. Many mature client libraries, such as Authlib or the OpenID libraries, fetch and parse this document for you automatically. Using discovery keeps your configuration DRY: when a provider adds a new endpoint, your client picks it up without a code change. For your own organization, publishing a discovery document for internal OIDC providers gives every team the same convenience.

# OIDC Discovery endpoint

# GET https://accounts.google.com/.well-known/openid-configuration

discovery_document = {
    "issuer": "https://accounts.google.com",
    "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
    "token_endpoint": "https://oauth2.googleapis.com/token",
    "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
    "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
    "response_types_supported": ["code", "token", "id_token"],
    "subject_types_supported": ["public"],
    "id_token_signing_alg_values_supported": ["RS256"]
}

# Use discovery to configure client
import requests

def get_oidc_config(issuer):
    well_known = f"{issuer}/.well-known/openid-configuration"
    return requests.get(well_known).json()

config = get_oidc_config("https://accounts.google.com")

Tokens are the closest thing OAuth has to physical keys, so how you create, store, and validate them determines the security of your whole system. The first rule is that no token should live forever: access tokens should be short-lived so that a leak has a bounded window of harm, while refresh tokens can be longer-lived but must be stored far more carefully. The second rule is that a token’s lifetime and storage strategy should follow directly from what it is used for, which is why the three token types below are treated separately. The overall goal is to make sure that a stolen token has the smallest possible value to an attacker: short life, narrow scope, and no way to be reused.

Token Security

Each token type exists to solve a different problem and therefore has a different threat model. Access tokens grant API access and should be kept in server memory in a single-page application so that script injection cannot read them. Refresh tokens are the master keys of the system—they can mint new access tokens indefinitely—so they belong on the server, encrypted at rest. ID tokens are purely for authentication and have short lifetimes with no refresh path. The listing below captures these distinctions at a glance.

Token Types

The storage column is worth reading twice. Putting a long-lived refresh token in localStorage is effectively the same as putting the user’s password in the browser: any XSS vulnerability exfiltrates it instantly. Keeping access tokens in memory and refresh tokens in HTTP-only cookies means that even a successful script injection cannot obtain the master credential, because JavaScript cannot read an HTTP-only cookie at all. This is the single most impactful storage decision you will make, because it protects the entire session from the most common class of web vulnerabilities.

# Access Token - Short-lived
access_token = {
    "lifetime": "15 minutes to 1 hour",
    "purpose": "Access protected resources",
    "storage": "Server memory (not localStorage!)"
}

# Refresh Token - Long-lived
refresh_token = {
    "lifetime": "Days to weeks",
    "purpose": "Get new access tokens",
    "storage": "Secure server-side storage, encrypted"
}

# ID Token - For authentication
id_token = {
    "lifetime": "15 minutes to 1 hour",
    "purpose": "Verify user identity",
    "storage": "Session storage"
}

Storage is where most real-world OAuth failures happen, so the pattern is worth internalizing. For a single-page application, the access token should live in memory only and be re-obtained on page reload, either through a silent refresh or via the refresh token in an HTTP-only cookie. For a backend, refresh tokens must always be encrypted at rest so that a database breach does not yield usable credentials. The example below shows both the frontend storage decisions and a small server-side encrypted token store.

Token Storage

The TokenStore class uses Fernet symmetric encryption to protect refresh tokens before they touch the database. Even if the database is exfiltrated, an attacker without the key cannot decrypt the tokens to mint new access tokens. The trade-off is that you now own the key management problem: the encryption key must be stored outside the database and rotated on a schedule. This is a case where a managed secret store or a cloud KMS is strongly preferable to hardcoding the key in application code. Encryption at rest converts a catastrophic database breach into a much more contained incident, which is exactly the outcome you want for tokens that can mint sessions.

# SECURE storage

# Frontend (SPA)
secure_storage = {
    "access_token": {
        "where": "JavaScript memory only",
        "why": "XSS can't steal from memory"
    },
    
    "refresh_token": {
        "where": "HTTP-only cookie (server-side)",
        "why": "JavaScript can't read HTTP-only cookies"
    }
}

# Backend - Always encrypt refresh tokens
import cryptography

class TokenStore:
    def __init__(self, key):
        self.cipher = cryptography.fernet.Fernet(key)
    
    def store(self, user_id, refresh_token):
        encrypted = self.cipher.encrypt(refresh_token.encode())
        db.save(user_id, encrypted)
    
    def get(self, user_id):
        encrypted = db.get(user_id)
        return self.cipher.decrypt(encrypted).decode()

Obtaining tokens correctly is only half the job; validating them correctly is what prevents attackers from forging or replaying them. Every JWT must be checked for a valid signature from a key published by the issuer, a matching audience, an expected issuer, and an expiration time that has not passed. The example below fetches the provider’s JSON Web Key Set, selects the key that matches the token’s kid header, and then decodes the token with all the standard validations applied. When you expose your own resource server, this validation is the security boundary between “requests carrying a valid token” and “anything else.”

Validating Tokens

There are a few subtleties here. The code fetches the JWKS on every call, which is correct but not optimal—in production you should cache the keys and refresh them on cache misses, since they change infrequently. Libraries like PyJWT handle signature verification automatically when given the right algorithm whitelist; never pass an empty algorithms list, because that can enable algorithm-confusion attacks. Finally, always validate the token against the exact issuer and audience you expect, and treat any exception during validation as an authentication failure. A common pattern is to centralize this validation in a single middleware or decorator so that no route accidentally skips it.

# Validate JWT (ID Token or Access Token)

import jwt

def validate_token(token, jwks_uri, issuer, audience):
    # Get signing keys
    jwks = requests.get(jwks_uri).json()
    
    # Find key for token
    unverified_header = jwt.get_unverified_header(token)
    signing_key = None
    
    for key in jwks["keys"]:
        if key["kid"] == unverified_header["kid"]:
            signing_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
            break
    
    if not signing_key:
        raise ValueError("No matching signing key")
    
    # Verify and decode
    payload = jwt.decode(
        token,
        signing_key,
        algorithms=["RS256"],
        audience=audience,
        issuer=issuer
    )
    
    return payload

Security in OAuth is mostly about avoiding a small set of well-documented attacks: redirect URI manipulation, CSRF on the callback, authorization code interception, and token theft via the browser. The recommendations in this section address each of these in order. HTTPS everywhere is non-negotiable because tokens travel in request bodies and headers; validate the redirect URI against an exact allowlist rather than a prefix match; and always send the state parameter to bind the callback to the request that started it. None of these are exotic attacks—they are the standard techniques documented in the OAuth threat model, and every one of them is defeated by a configuration you already control.

Security Best Practices

The checklist below is organized by where the risk lives. Under basic transport and request security, the priorities are HTTPS, exact redirect URI validation, CSRF protection via state, and PKCE. Token security focuses on lifetimes and revocation. Client security addresses the fact that anything shipped to a browser is public—so a secret can never live there, and PKCE is mandatory for public clients. Finally, scope discipline ensures you request and trust only the minimum set of permissions the application actually needs. Working through this list at design time is far cheaper than discovering a vulnerability after launch.

OAuth 2.0 Security

Two items in this list deserve emphasis because they are routinely skipped. First, validate the scopes you actually receive in the token response rather than assuming the server granted everything you asked for. Second, never trust the scope parameter the client sends back as proof of anything—the authorization server’s token response is the authoritative source. Skipping these checks is how applications end up with broader access than they requested. Treat the token response as the only truth about what was granted, and re-check it on every API call where the scope matters.

# Security recommendations

security_basics:
  - "Use HTTPS everywhere"
  - "Validate redirect_uri exactly"
  - "Use state parameter for CSRF"
  - "Use code verifier for PKCE"

token_security:
  - "Short-lived access tokens"
  - "Long-lived, secure refresh tokens"
  - "Never expose tokens in URLs"
  - "Implement token revocation"

client_security:
  - "Never store client_secret in frontend"
  - "Use PKCE for public clients"
  - "Validate all responses"

scopes:
  - "Request minimum necessary scopes"
  - "Don't trust scope parameter in token exchange"
  - "Validate granted scopes"

PKCE protects public clients—single-page apps, mobile apps, and native apps that cannot keep a client secret confidential. The mechanism works by having the client generate a random verifier, send only a hashed challenge in the authorization request, and then present the original verifier during the token exchange. If an attacker intercepts the authorization code, they cannot redeem it because they do not know the verifier. What used to be an optional extra for mobile apps is now recommended, and in many providers required, for all authorization code flows. It is one of the rare security controls that adds almost no implementation cost while closing a real attack vector.

PKCE (Proof Key for Code Exchange)

The implementation below generates a URL-safe random verifier and derives the challenge using SHA-256, per the S256 method that providers prefer. The verifier is held on the client and submitted only at the token endpoint, while only the challenge is exposed in the authorization URL. This asymmetry is the entire security property: the code and the verifier travel on different channels, so a single interception is not enough to hijack the flow. Note that the challenge is a hash of the verifier, never the verifier itself, so even reading the authorization URL reveals nothing usable to an attacker.

# PKCE for additional security

class PKCE:
    """PKCE implementation"""
    
    def __init__(self):
        self.code_verifier = secrets.token_urlsafe(32)
        self.code_challenge = self._generate_challenge()
    
    def _generate_challenge(self):
        # SHA256 hash of verifier, base64url encoded
        digest = hashlib.sha256(self.code_verifier.encode()).digest()
        return base64.urlsafe_b64encode(digest).decode()[:-1]
    
    def get_authorization_params(self):
        return {
            "code_challenge": self.code_challenge,
            "code_challenge_method": "S256"
        }
    
    def get_token_params(self, code):
        return {
            "code_verifier": self.code_verifier
        }

Combining the authorization code flow with PKCE gives you a defense-in-depth posture appropriate for modern applications. The complete example below ties together everything: state for CSRF protection, PKCE for code interception protection, and a server-side exchange that keeps tokens out of the browser. Note that even in this flow the client still uses the same token endpoint—PKCE only adds two parameters to the requests we have already seen. The elegance of the design is that no new endpoints or round trips are needed; the security properties come entirely from the two extra parameters.

Complete Flow with PKCE

The important detail in this combined implementation is that the PKCE verifier must be persisted in the session between the /login and /callback requests, because the two HTTP requests are otherwise stateless. Losing the verifier between requests causes the token exchange to fail with a provider error. Notice also that the code_challenge_method is explicitly declared as S256; providers that support only the plain method are weaker, and modern providers reject flows that omit the method entirely. If you are using a framework that stores sessions in cookies or a key-value store, make sure the verifier survives a page reload, which some naive implementations do not.

# Authorization Code + PKCE

@app.route("/login")
def login():
    pkce = PKCE()
    session["pkce"] = pkce
    
    params = {
        "response_type": "code",
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": "openid profile email",
        "state": secrets.token_hex(16),
        **pkce.get_authorization_params()
    }
    
    return redirect(f"{AUTH_URL}?{urllib.parse.urlencode(params)}")

@app.route("/callback")
def callback():
    pkce = session["pkce"]
    
    # Exchange code with verifier
    token_response = requests.post(
        TOKEN_URL,
        data={
            "grant_type": "authorization_code",
            "code": request.args.get("code"),
            "redirect_uri": REDIRECT_URI,
            "client_id": CLIENT_ID,
            **pkce.get_token_params(request.args.get("code"))
        }
    ).json()
    
    return token_response

Taken together, the authorization code flow with PKCE, OIDC’s ID tokens, and disciplined token storage form a robust, standards-compliant authentication and authorization stack. The security posture comes from layering: short-lived access tokens limit the blast radius, refresh tokens stay encrypted and server-side, and state plus PKCE neutralize the two most common web attacks. When you are ready to integrate, the strongest recommendation is to use a mature, well-audited client library rather than assembling these primitives yourself—the complexity of correct validation and revocation is exactly where security bugs hide. Start from the happy path above, then add the production concerns of key rotation, revocation, and monitoring one at a time.

Conclusion

OAuth 2.0 and OIDC are essential for modern authentication:

  • Authorization Code + PKCE: Best for most applications
  • OpenID Connect: Adds authentication layer on OAuth
  • Tokens: Short-lived access, long-lived refresh
  • Security: PKCE, HTTPS, proper validation

Always use established libraries rather than implementing OAuth yourself.


Comments

👍 Was this article helpful?