How to Secure Your APIs with OAuth 2.0 and JWT

How to Secure Your APIs with OAuth 2.0 and JWT
Photo by Douglas Lopes / Unsplash

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.

Build with 4Geeks

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 (.):

  1. 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"}
  2. 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.
  3. 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.

  1. Client Initiates Flow: The client application first generates a cryptographically random string called the code_verifier. It then creates a code_challenge by SHA256-hashing the verifier and Base64Url-encoding the result.
  2. Redirect to Authorization Server: The client redirects the user's browser to the Authorization Server's /authorize endpoint, including parameters like client_id, redirect_uri, scope, response_type=code, and the generated code_challenge and code_challenge_method=S256.
  3. 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).
  4. Authorization Code Grant: Upon successful authentication and consent, the Authorization Server redirects the user back to the client's redirect_uri with a temporary, one-time-use authorization code.
  5. 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.
  6. 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>
  7. 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.json endpoint) 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 Scopes for Authorization: After validation, inspect the scp claim to ensure the token grants sufficient permissions for the requested operation. For example, a request to DELETE /api/users/123 might require the users:delete scope.

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.

Build with 4Geeks

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.json endpoint) 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, the aud (audience) to ensure the token was intended for this specific API, and the exp (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.

  1. The client app generates a secret code_verifier and a transformed version called a code_challenge.
  2. It redirects the user to the authorization server with the code_challenge.
  3. The user logs in and grants consent.
  4. The server sends a temporary authorization_code back to the client.
  5. The client sends this authorization_code and the original code_verifier to the token endpoint.
  6. The authorization server validates the code_verifier against the code_challenge it stored. If they match, it issues the access token (JWT). This process ensures that even if the authorization_code is intercepted, it is useless without the code_verifier, preventing interception attacks.

Read more

How to Architect a Multi-Tenant SaaS Application on Kubernetes

How to Architect a Multi-Tenant SaaS Application on Kubernetes

Multi-tenancy is a foundational architectural principle for most Software-as-a-Service (SaaS) products, enabling cost-effective scaling by serving multiple customers (tenants) from a single application instance. Kubernetes has emerged as the de facto standard for orchestrating containerized applications, but architecting a secure, scalable, and isolated multi-tenant system on it requires deliberate design

By Allan Porras