How to Secure Your APIs with OAuth 2.0 and JWT
In any distributed system, the contract defined by an API is only as strong as its security model. Unprotected or improperly secured endpoints represent a critical vulnerability, exposing sensitive data and business logic to unauthorized access. For CTOs and engineering leaders, establishing a robust, scalable, and industry-standard security architecture is not an option—it's a foundational requirement. The combination of OAuth 2.0 for authorization and JSON Web Tokens (JWT) for access credentials has emerged as the definitive solution for this challenge.
This article provides a detailed technical blueprint for implementing API security using OAuth 2.0 and JWTs. We will move beyond high-level theory to explore the architectural flow, critical validation steps, and provide actionable code examples for building a secure resource server.
The Core Components: Framework and Token
It's crucial to understand that OAuth 2.0 and JWT serve distinct but complementary purposes. Mistaking one for the other is a common architectural error.
OAuth 2.0: The Authorization Framework
OAuth 2.0 is an authorization framework, not an authentication protocol. Its primary purpose is to enable a client application to access resources on behalf of a user (the resource owner) without exposing the user's credentials to the client. It standardizes the flow of delegated access.
The framework defines four key roles:
- Resource Owner: The user who owns the data and grants permission.
- Client: The application (e.g., a web front end, mobile app) requesting access to the resource owner's data.
- Authorization Server (AS): The server that authenticates the resource owner and issues access tokens after consent is granted. This is the central security authority.
- Resource Server (RS): The API server that hosts the protected resources and accepts/validates access tokens. This is the backend service you are building.
Product Engineering Services
Work with our in-house Project Managers, Software Engineers and QA Testers to build your new custom software product or to support your current workflow, following Agile, DevOps and Lean methodologies.
For modern public clients (like SPAs or mobile apps), the Authorization Code Grant with Proof Key for Code Exchange (PKCE) is the recommended flow. It provides a secure way to obtain tokens without exposing any client secrets in the user agent, mitigating authorization code interception attacks.
JSON Web Tokens (JWT): The Access Credential
A JWT is a compact, URL-safe means of representing claims to be transferred between two parties. In our context, it is the format for the access token issued by the Authorization Server. Its stateless nature is its most powerful feature; the Resource Server can validate a JWT and ascertain the user's identity and permissions without needing to make a database call or contact the Authorization Server.
A JWT consists of three parts, separated by dots (.):
- Header: Contains metadata about the token, including the signing algorithm (alg, e.g., RS256) and token type (typ, e.g., JWT).{"alg": "RS256", "typ": "JWT"}
- Payload: Contains the claims, which are statements about the entity (typically the user) and additional metadata. Key standard claims include:
iss(Issuer): The Authorization Server that issued the token.sub(Subject): The unique identifier of the resource owner.aud(Audience): The intended recipient of the token (your Resource Server/API).exp(Expiration Time): The timestamp after which the token is invalid.iat(Issued At): The timestamp when the token was issued.scp(Scope): A space-delimited list of permissions granted by the user.
- Signature: A cryptographic signature used to verify the token's integrity. It is created by signing the encoded header and payload with a private key held by the Authorization Server. The Resource Server uses the corresponding public key to validate it.HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
Architectural Blueprint: The Authorization Code + PKCE Flow
Let's dissect the end-to-end flow, focusing on the interactions between the client, Authorization Server, and your Resource Server.
- Client Initiates Flow: The client application first generates a cryptographically random string called the
code_verifier. It then creates acode_challengeby SHA256-hashing the verifier and Base64Url-encoding the result. - Redirect to Authorization Server: The client redirects the user's browser to the Authorization Server's
/authorizeendpoint, including parameters likeclient_id,redirect_uri,scope,response_type=code, and the generatedcode_challengeandcode_challenge_method=S256. - User Authentication and Consent: The user interacts with the Authorization Server, entering their credentials (authentication) and approving the permissions (scopes) requested by the client (consent).
- Authorization Code Grant: Upon successful authentication and consent, the Authorization Server redirects the user back to the client's
redirect_uriwith a temporary, one-time-use authorization code. - Token Exchange: The client's backend sends a POST request to the Authorization Server's /token endpoint. This request is made from server to server (not via the browser) and includes the received authorization code along with the original code_verifier.The Authorization Server validates the code_verifier against the code_challenge from the initial request. If they match, it proves the token request is coming from the same client that initiated the flow. It then returns an access token (JWT) and a refresh token.
- API Call with JWT: The client stores the access token and includes it in the Authorization header of every request to your protected API (the Resource Server).Authorization: Bearer <your_jwt_here>
- Resource Server Validates JWT: This is the most critical step for your API. Upon receiving a request, your Resource Server must perform a series of stateless validations before executing any business logic:
- Verify the Signature: Fetch the Authorization Server's public key (typically from a
.well-known/jwks.jsonendpoint) and use it to verify the JWT's signature. This proves the token was issued by the trusted AS and has not been tampered with. - Validate Standard Claims:
- Check if the
iss(issuer) claim matches the expected Authorization Server. - Check if the
aud(audience) claim matches your API's unique identifier. - Check if the current time is before the
exp(expiration) timestamp.
- Check if the
- Check Scopes for Authorization: After validation, inspect the
scpclaim to ensure the token grants sufficient permissions for the requested operation. For example, a request toDELETE /api/users/123might require theusers:deletescope.
- Verify the Signature: Fetch the Authorization Server's public key (typically from a
Only if all these checks pass should the request be processed.
Practical Implementation: Securing a Python API
Let's implement the JWT validation logic (Step 7) in a Python API using FastAPI. This example assumes you have an Authorization Server (like Auth0, Okta, or Keycloak) that provides a JWKS endpoint.
Dependencies
pip install "fastapi[all]" "python-jose[cryptography]" "requests"
JWT Validation Logic
We will create a reusable dependency that handles token extraction and validation. This code fetches the public keys from the JWKS URI, caches them to avoid network latency on every request, and performs the validation steps.
# src/security.py
import os
import requests
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from functools import lru_cache
# --- Configuration ---
# In a real app, load these from environment variables or a config file.
AUTH_SERVER_URL = "https://your-auth-server.com/"
API_AUDIENCE = "https://api.yourapp.com"
ALGORITHMS = ["RS256"]
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{AUTH_SERVER_URL}oauth/token")
# --- JWKS Caching and Public Key Retrieval ---
@lru_cache()
def get_jwks():
"""
Fetches the JSON Web Key Set (JWKS) from the authorization server.
Uses lru_cache for in-memory caching to improve performance.
"""
try:
response = requests.get(f"{AUTH_SERVER_URL}.well-known/jwks.json")
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Could not connect to Authorization Server: {e}"
)
def get_signing_key(token: str):
"""
Finds the appropriate public key from the JWKS to verify the token's signature.
"""
try:
unverified_header = jwt.get_unverified_header(token)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token header"
)
rsa_key = {}
jwks = get_jwks()
for key in jwks["keys"]:
if key["kid"] == unverified_header["kid"]:
rsa_key = {
"kty": key["kty"],
"kid": key["kid"],
"use": key["use"],
"n": key["n"],
"e": key["e"],
}
if not rsa_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Signing key not found in JWKS"
)
return rsa_key
# --- Main Token Validation Dependency ---
async def validate_token(token: str = Depends(oauth2_scheme)) -> dict:
"""
Validates a JWT token and returns its payload if valid.
This function acts as a FastAPI dependency.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
signing_key = get_signing_key(token)
try:
payload = jwt.decode(
token,
signing_key,
algorithms=ALGORITHMS,
audience=API_AUDIENCE,
issuer=AUTH_SERVER_URL
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired"
)
except jwt.JWTClaimsError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid claims: {e}"
)
except JWTError:
raise credentials_exception
Product Engineering Services
Work with our in-house Project Managers, Software Engineers and QA Testers to build your new custom software product or to support your current workflow, following Agile, DevOps and Lean methodologies.
Protecting an Endpoint
With the validation dependency created, securing an endpoint is clean and declarative. We can also add another dependency to check for required scopes.
# src/main.py
from fastapi import FastAPI, Depends, HTTPException, status
from pydantic import BaseModel
from typing import List
from .security import validate_token
app = FastAPI()
class Item(BaseModel):
id: int
name: str
# --- Scope Checking Dependency ---
def require_scope(required_scopes: List[str]):
"""
A dependency factory to check if the token has the required scopes.
"""
async def scope_checker(payload: dict = Depends(validate_token)):
token_scopes = payload.get("scope", "").split()
for scope in required_scopes:
if scope not in token_scopes:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required scope: {scope}"
)
return payload
return scope_checker
# --- Protected Endpoints ---
@app.get("/api/items", response_model=List[Item])
async def read_items(
# This endpoint requires a valid token with the "items:read" scope
payload: dict = Depends(require_scope(["items:read"]))
):
# The `sub` claim from the token payload can be used to identify the user
user_id = payload.get("sub")
print(f"Fetching items for user: {user_id}")
return [{"id": 1, "name": "Widget"}, {"id": 2, "name": "Gadget"}]
@app.post("/api/items", response_model=Item)
async def create_item(
item: Item,
# This endpoint requires a valid token with the "items:write" scope
payload: dict = Depends(require_scope(["items:write"]))
):
print(f"User {payload.get('sub')} is creating an item.")
# Business logic to create the item...
return item
This implementation demonstrates a robust and reusable pattern. The security logic is decoupled from the business logic, and endpoints declaratively state their security requirements.
Final Considerations
- Token Lifetime: Keep access token lifetimes short (e.g., 5-15 minutes) to limit the impact of a compromised token. Use long-lived refresh tokens, stored securely on the client, to obtain new access tokens without requiring user re-authentication.
- Token Revocation: While JWTs are stateless, you may need a mechanism to revoke access before expiration (e.g., when a user logs out or changes their password). This typically requires introducing a stateful layer, such as a revocation list checked by the API, which trades some of the benefits of statelessness for enhanced security control.
- Don't Store Sensitive Data in JWTs: The JWT payload is Base64Url-encoded, not encrypted. It is publicly readable. Never store sensitive information like passwords or PII directly in the payload.
By implementing the OAuth 2.0 framework with JWTs, you build an API security model that is not only robust and compliant with modern standards but also highly scalable and decoupled, enabling your services to grow securely.
FAQs
What is the difference between OAuth 2.0 and JWT?
OAuth 2.0 is an authorization framework, not an authentication protocol. Its main purpose is to allow a client application to access resources on behalf of a user without exposing that user's credentials. It defines the roles and flows for delegating access. A JSON Web Token (JWT), on the other hand, is a credential format. It is a compact, URL-safe token that represents claims between two parties. In this context, the JWT is the access token issued by an OAuth 2.0 authorization server, which the API (Resource Server) can validate to confirm a user's identity and permissions.
How does an API validate a JSON Web Token (JWT)?
An API (or Resource Server) must perform several key validation steps before trusting a JWT and processing a request.
- Verify the Signature: The server fetches the authorization server's public key (often from a
.well-known/jwks.jsonendpoint) and uses it to cryptographically verify that the token's signature is valid and was issued by the trusted authority. - Validate Standard Claims: The server checks critical claims in the token's payload, such as the
iss(issuer) to ensure it's from the correct source, theaud(audience) to ensure the token was intended for this specific API, and theexp(expiration) to ensure the token has not expired. - Check Scopes: After validation, the server inspects the
scp(scope) claim to ensure the token grants sufficient permissions for the specific operation being requested (e.g.,users:delete).
What is the Authorization Code Grant with PKCE flow?
The Authorization Code Grant with Proof Key for Code Exchange (PKCE) is the recommended and most secure OAuth 2.0 flow for modern applications like single-page apps (SPAs) and mobile apps.
- The client app generates a secret
code_verifierand a transformed version called acode_challenge. - It redirects the user to the authorization server with the
code_challenge. - The user logs in and grants consent.
- The server sends a temporary
authorization_codeback to the client. - The client sends this
authorization_codeand the originalcode_verifierto the token endpoint. - The authorization server validates the
code_verifieragainst thecode_challengeit stored. If they match, it issues the access token (JWT). This process ensures that even if theauthorization_codeis intercepted, it is useless without thecode_verifier, preventing interception attacks.