DEV Community

Cover image for Securing Web APIs
Shoumik Chakravarty
Shoumik Chakravarty

Posted on

Securing Web APIs

A Practical Guide to Authentication & Authorization Methods

Most API security incidents don't happen because attackers found a clever zero-day. They happen because a developer grabbed the first auth pattern that came to mind, shipped it, and moved on.

I've seen API keys committed to public repos, JWTs without expiry running in production, and OAuth flows that skip PKCE on mobile clients. These aren't exotic mistakes — they're the default outcome when engineers don't have a clear map of what each method does, when it fits, and where it breaks down.

This guide gives you that map. We'll cover every major authentication and authorization method used to secure web APIs today, with code examples in Python and a decision matrix at the end so you can match the right tool to your specific context.


Authentication vs. Authorization — Get This Right First

These two words are often used interchangeably. That's a problem, because conflating them leads to real vulnerabilities.

  • Authentication answers: Who are you? It verifies identity.
  • Authorization answers: What are you allowed to do? It enforces permissions.

A request can be authenticated (we know it's from User A) but unauthorized (User A doesn't have access to this resource). A system that only checks "is this a valid token?" without checking "does this token have permission to do this?" is wide open to privilege escalation.

Keep both in mind as we go through each method.


1. API Keys

How it works

An API key is a long, random string generated by the server and shared with the client. The client sends it on every request, typically in a header:

GET /v1/data HTTP/1.1
Host: api.example.com
X-API-Key: sk_live_a3f8c2d1e9b7...
Enter fullscreen mode Exit fullscreen mode

On the server side, you validate it against your store:

import secrets
from functools import wraps
from flask import Flask, request, jsonify

app = Flask(__name__)

# In production, store these in a database with metadata
# (owner, scopes, rate limit tier, created_at, last_used)
VALID_KEYS = {
    "sk_live_a3f8c2d1e9b7abc123": {"owner": "client_A", "scopes": ["read"]},
    "sk_live_z9y8x7w6v5u4t3s2r1": {"owner": "client_B", "scopes": ["read", "write"]},
}

def require_api_key(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        key = request.headers.get("X-API-Key")
        if not key or key not in VALID_KEYS:
            return jsonify({"error": "Invalid or missing API key"}), 401
        request.api_client = VALID_KEYS[key]
        return f(*args, **kwargs)
    return decorated

@app.route("/v1/data")
@require_api_key
def get_data():
    return jsonify({"message": f"Hello, {request.api_client['owner']}"})
Enter fullscreen mode Exit fullscreen mode

When to use it

API keys work well for server-to-server communication where the client is a known system (not an end user), especially for public APIs where you want to track usage per consumer, enforce rate limits, or tier access. They're also the right choice for simple internal tooling where OAuth 2.0 would be overkill.

Security tradeoffs

  • Keys don't expire by default — a leaked key is valid until you rotate it manually.
  • They carry no user identity, only client identity.
  • Never put them in URLs (they end up in server logs). Always use headers.
  • Use secrets.compare_digest() instead of == to prevent timing attacks when comparing keys.

2. Basic Authentication

How it works

The client encodes username:password in Base64 and sends it in the Authorization header on every request:

GET /v1/resource HTTP/1.1
Authorization: Basic c2hvdW1pazpteXBhc3N3b3Jk
Enter fullscreen mode Exit fullscreen mode
import base64
from flask import request, jsonify

def check_basic_auth():
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Basic "):
        return None
    try:
        decoded = base64.b64decode(auth[6:]).decode("utf-8")
        username, password = decoded.split(":", 1)
        return username, password
    except Exception:
        return None
Enter fullscreen mode Exit fullscreen mode

When to use it

Basic Auth is appropriate for internal tools, admin dashboards behind a VPN, or simple scripts that call internal APIs. It is always required over HTTPS — Base64 is encoding, not encryption, and credentials are trivially reversible over plain HTTP.

Security tradeoffs

  • Credentials are sent on every single request. One intercepted request = credentials compromised.
  • No token expiry, no scoping, no revocation without a password change.
  • Avoid for any externally exposed or user-facing API. It has no place in a mobile or browser client.

3. JWT — JSON Web Tokens

How it works

A JWT is a self-contained, signed token with three Base64URL-encoded parts: a header (algorithm), a payload (claims), and a signature. The server signs it on login; the client sends it back on every subsequent request. The server verifies the signature without hitting a database.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9    header
.eyJ1c2VyX2lkIjoiMTIzIiwiZXhwIjoxNzE...   payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_...   signature
Enter fullscreen mode Exit fullscreen mode
import jwt
import datetime
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET_KEY = "use-a-strong-secret-from-env-not-hardcoded"

def generate_token(user_id: str) -> str:
    now = datetime.datetime.now(datetime.timezone.utc)
    payload = {
        "sub": user_id,
        "iat": now,
        "exp": now + datetime.timedelta(hours=1),
        "scopes": ["read", "write"],
    }
    return jwt.encode(payload, SECRET_KEY, algorithm="HS256")

def verify_token(token: str) -> dict:
    try:
        # Always specify algorithms explicitly — never pass algorithms=None
        return jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
        raise ValueError("Token has expired")
    except jwt.InvalidTokenError:
        raise ValueError("Invalid token")

@app.route("/login", methods=["POST"])
def login():
    # Validate credentials here (omitted for brevity)
    token = generate_token(user_id="user_123")
    return jsonify({"access_token": token, "token_type": "Bearer"})

@app.route("/v1/profile")
def profile():
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return jsonify({"error": "Missing token"}), 401
    try:
        claims = verify_token(auth[7:])
        return jsonify({"user_id": claims["sub"]})
    except ValueError as e:
        return jsonify({"error": str(e)}), 401
Enter fullscreen mode Exit fullscreen mode

When to use it

JWTs shine in stateless, distributed systems — microservices, APIs behind a CDN, or anywhere you can't share session state between servers. They're the standard for single-page apps and mobile clients that authenticate once and carry claims across services.

The most common JWT mistakes

Mistake Consequence Fix
alg: none accepted Anyone can forge a valid token Always specify algorithms=["HS256"] explicitly
No expiry (exp claim missing) Leaked tokens are valid forever Always set exp; 15–60 min for access tokens
Secret stored in code Any repo leak = all tokens compromised Load from environment variable or secrets manager
Storing JWT in localStorage XSS can steal it Use HttpOnly cookies or a BFF pattern
Trusting alg header from client Algorithm confusion attacks Pin algorithm server-side

4. OAuth 2.0

How it works

OAuth 2.0 is a delegation framework, not an authentication protocol. It lets a user grant a third-party application limited access to their resources without sharing their password.

The most important flows:

Authorization Code + PKCE (for browser/mobile clients — the only safe flow for public clients):

User → App → Authorization Server
  ← Authorization Code
App → Authorization Server (code + PKCE verifier)
  ← Access Token + Refresh Token
App → Resource Server (Access Token)
  ← Protected Resource
Enter fullscreen mode Exit fullscreen mode

Client Credentials (for machine-to-machine, no user involved):

import httpx

def get_machine_token(client_id: str, client_secret: str, token_url: str) -> str:
    response = httpx.post(token_url, data={
        "grant_type": "client_credentials",
        "client_id": client_id,
        "client_secret": client_secret,
        "scope": "api:read api:write",
    })
    response.raise_for_status()
    return response.json()["access_token"]

# Use the token
token = get_machine_token(
    client_id="my-service",
    client_secret="from-env",
    token_url="https://auth.example.com/oauth/token"
)

headers = {"Authorization": f"Bearer {token}"}
Enter fullscreen mode Exit fullscreen mode

When to use it

Use OAuth 2.0 whenever you need delegated access — a user authorizing your app to act on their behalf with another service (Google Drive, GitHub, Stripe). Also use Client Credentials for any service-to-service communication within a trusted internal network.

Critical: always use PKCE for public clients

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. Any OAuth flow running in a browser or mobile app that cannot securely store a client secret must use PKCE. It is no longer optional — the OAuth 2.1 draft mandates it for all flows.


5. OpenID Connect (OIDC)

How it works

OpenID Connect is an authentication layer built on top of OAuth 2.0. While OAuth 2.0 says "here is an access token that grants you access to a resource," OIDC adds an ID Token (a JWT) that asserts the user's identity.

# After OAuth flow completes, decode the ID token
import jwt
from jwt import PyJWKClient

def verify_id_token(id_token: str, issuer: str, client_id: str) -> dict:
    # Fetch public keys from the provider's JWKS endpoint
    jwks_client = PyJWKClient(f"{issuer}/.well-known/jwks.json")
    signing_key = jwks_client.get_signing_key_from_jwt(id_token)

    return jwt.decode(
        id_token,
        signing_key.key,
        algorithms=["RS256"],
        audience=client_id,
        issuer=issuer,
    )

claims = verify_id_token(
    id_token=token_response["id_token"],
    issuer="https://accounts.google.com",
    client_id="your-client-id",
)
print(claims["email"])   # Verified user email
print(claims["sub"])     # Stable user identifier
Enter fullscreen mode Exit fullscreen mode

When to use it

Any time you need "Sign in with Google/GitHub/Microsoft" or need to verify who the user is (not just what they can access), OIDC is the right choice. It eliminates the need to manage passwords entirely for federated identity scenarios.


6. HMAC Request Signing

How it works

Instead of sending a credential, the client uses a shared secret to generate a cryptographic signature of the request itself — the method, URL, timestamp, and body. The server recomputes the signature and compares.

import hmac
import hashlib
import time
import base64

def sign_request(method: str, path: str, body: bytes, secret: str) -> dict:
    timestamp = str(int(time.time()))
    message = f"{method}\n{path}\n{timestamp}\n{hashlib.sha256(body).hexdigest()}"
    signature = base64.b64encode(
        hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest()
    ).decode()
    return {
        "X-Timestamp": timestamp,
        "X-Signature": signature,
    }

def verify_request(method: str, path: str, body: bytes,
                   secret: str, headers: dict) -> bool:
    timestamp = headers.get("X-Timestamp", "")
    # Reject requests older than 5 minutes — prevents replay attacks
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = sign_request(method, path, body, secret)
    return hmac.compare_digest(headers.get("X-Signature", ""), expected["X-Signature"])
Enter fullscreen mode Exit fullscreen mode

When to use it

HMAC signing is the right choice for webhooks (Stripe, GitHub, and Twilio all use it), high-integrity financial APIs, and scenarios where you need to prove that a request hasn't been tampered with in transit. AWS Signature Version 4 is HMAC under the hood.

Why it's stronger than API keys for sensitive operations

An API key proves identity but not integrity. If an attacker can intercept and modify a request, the key is still valid. HMAC signs the entire request, so any modification breaks the signature. It also includes a timestamp, preventing replay attacks.


7. Mutual TLS (mTLS)

How it works

In standard TLS, only the server presents a certificate to prove its identity. In mTLS, both the client and server present certificates. This means the server cryptographically verifies the client's identity at the transport layer — before any application code runs.

import ssl
import httpx

# Client presents its certificate on every request
ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
ssl_context.load_verify_locations("ca-cert.pem")          # Trust this CA
ssl_context.load_cert_chain("client-cert.pem", "client-key.pem")  # Our identity

with httpx.Client(verify=ssl_context) as client:
    response = client.get("https://internal-service.example.com/api/data")
    print(response.json())
Enter fullscreen mode Exit fullscreen mode

On the server (nginx configuration example):

server {
    listen 443 ssl;
    ssl_certificate      /etc/ssl/server-cert.pem;
    ssl_certificate_key  /etc/ssl/server-key.pem;
    ssl_client_certificate /etc/ssl/ca-cert.pem;
    ssl_verify_client    on;   # Reject any request without a valid client cert

    location /api/ {
        proxy_pass http://app:8000;
        proxy_set_header X-Client-Cert $ssl_client_cert;
    }
}
Enter fullscreen mode Exit fullscreen mode

When to use it

mTLS is the standard for zero-trust architectures, service meshes (Istio, Linkerd), and any microservice communication where you need hardware-level proof of identity. If you're running services in Kubernetes and want no implicit trust between pods, mTLS is the foundation. It is operationally complex (certificate rotation, PKI management) but offers the strongest guarantees.


Decision Matrix

Use this table to match your scenario to the right method:

Scenario Recommended Method Why
Public API — third-party developers API Keys Simple to issue, rotate, and rate-limit per client
User logs in, gets access to their data JWT + OAuth 2.0 Stateless identity + delegated access
"Sign in with Google/GitHub" OpenID Connect Federated identity; no password management
Mobile / SPA accessing your API OAuth 2.0 (PKCE) PKCE protects public clients; never Client Credentials here
Service A calls Service B internally OAuth 2.0 (Client Credentials) or mTLS No user involved; machine-to-machine trust
Webhook endpoint receiving events HMAC signature verification Verify payload integrity and origin
Zero-trust / service mesh mTLS Transport-layer mutual identity, before any app code
Internal admin tool / script Basic Auth over HTTPS Simple, acceptable for low-risk internal use only
High-integrity financial / audit API HMAC + JWT combined Identity (JWT) + tamper-evidence (HMAC)

Common Mistakes Across All Methods

1. Mixing up 401 and 403
Return 401 Unauthorized when the request has no valid credentials. Return 403 Forbidden when credentials are valid but the user lacks permission. Many APIs return 403 for everything — this breaks standard OAuth client libraries and confuses consumers.

2. Not validating token audience (aud)
A JWT from your auth server for Service A should not be accepted by Service B. Always validate the aud claim. An attacker who obtains a valid token for one service shouldn't be able to reuse it elsewhere.

3. Logging sensitive headers
It's shockingly common to log the full Authorization header in debug mode. One log aggregation misconfiguration and those tokens are in your SIEM or your cloud provider's log storage. Scrub auth headers before logging.

4. No token rotation
Access tokens expire (they should). Refresh tokens don't have to, but they need to be rotatable. Implement refresh token rotation — when a refresh token is used, issue a new one and invalidate the old one. If you detect reuse of an already-rotated token, revoke the entire family.

5. Trusting client-provided claims
Your API should verify tokens, not trust whatever claims a client sends in a custom header. Never do user_id = request.headers.get("X-User-Id") as your authorization check.


Conclusion

There's no single "best" authentication method — there's only the right method for the trust model, client type, and sensitivity level of your specific API. The biggest mistake isn't choosing the wrong method; it's treating auth as an afterthought and never revisiting the decision as your system evolves.

To recap:

  • API Keys for known clients and developer APIs.
  • Basic Auth only for internal tooling over HTTPS.
  • JWT for stateless, distributed systems.
  • OAuth 2.0 for delegated access and third-party integrations.
  • OIDC whenever you need verified user identity.
  • HMAC for webhooks and tamper-evident request signing.
  • mTLS for zero-trust service-to-service communication.

Start with the decision matrix. Then read the spec for whichever method you choose — the RFCs and official documentation are far shorter than you'd expect, and reading them directly will save you from the edge cases that bite teams in production.

Top comments (0)