WierdX — Programming Reference All tutorials →
Developer reference · Practical tutorials · CS fundamentals
API Design

API Authentication: JWT, OAuth 2.0, and API Keys Compared

Every API that serves more than public data needs to know who is making a request and what they are allowed to do. JWT, OAuth 2.0, and API keys each solve a different slice of that problem — and mixing them up is a frequent source of security bugs.

Published June 25, 2026

Authentication answers the question "who are you?" Authorization answers "what are you allowed to do?" Both are required before an API can serve a request safely, and the two are often conflated — especially because tokens like JWTs often carry both identity and permission claims in a single artifact.

Understanding the distinct mechanisms helps you pick the right one for each use case and avoid the class of bugs that comes from using a mechanism designed for one context in another.

API keys: the simplest approach

An API key is a long random string (typically 32–64 characters) that a service issues to a client. The client sends it with every request — usually in an Authorization header or a custom header like X-API-Key — and the server looks it up in a database to identify the caller.

# Generating a secure API key
import secrets
key = secrets.token_urlsafe(32)
# Produces something like: "vY3fK9mNpLqR2tX8wZbAcD6eHjMsUvWy..."

# Storing it: hash before saving, like a password
import hashlib
key_hash = hashlib.sha256(key.encode()).hexdigest()
db.save(user_id=user.id, key_hash=key_hash)

API keys are best for server-to-server communication, where a machine is calling your API and there is no end user involved. They are simple to implement, simple to rotate, and simple to understand. Their weaknesses are that they grant access but carry no claims about identity or permissions beyond what your database associates with the key, and they provide no mechanism for a user to grant a third party access to their account without giving that third party the key itself.

Never store API keys in plaintext. Hash them before saving to the database (SHA-256 is sufficient since API keys are long and random — unlike passwords, they do not need bcrypt's work factor to defend against brute force). Issue one key per client rather than sharing keys across clients: when a key is compromised, you revoke only that client's access.

JWT: self-contained tokens

A JSON Web Token (JWT) is a signed, base64url-encoded JSON document. It has three parts separated by dots: a header, a payload, and a signature.

# Anatomy of a JWT (after base64url decoding each segment)
header  = {"alg": "HS256", "typ": "JWT"}
payload = {
    "sub": "user_123",          # subject: who this token represents
    "iss": "api.example.com",   # issuer: who created it
    "aud": "app.example.com",   # audience: who should accept it
    "exp": 1750000000,          # expiration: Unix timestamp
    "iat": 1749996400,          # issued at
    "roles": ["editor"]         # custom claims: permissions
}
signature = HMAC_SHA256(
    base64url(header) + "." + base64url(payload),
    secret_key
)

The key property of a JWT is that the server issuing it signs the token with a secret (HMAC) or a private key (RS256, ES256). Any server that holds the corresponding secret or public key can verify the signature without making a database query. This makes JWTs stateless: you can verify a JWT on any server in a cluster without a shared session store.

import jwt

# Issuing a token
token = jwt.encode(
    {"sub": "user_123", "exp": time.time() + 3600, "roles": ["editor"]},
    secret_key,
    algorithm="HS256"
)

# Verifying (on any server that has secret_key)
try:
    claims = jwt.decode(token, secret_key, algorithms=["HS256"],
                        audience="app.example.com")
    user_id = claims["sub"]
except jwt.ExpiredSignatureError:
    return 401, "Token expired"
except jwt.InvalidTokenError:
    return 401, "Invalid token"

The tradeoff is revocation. Because verification is stateless, a JWT is valid until it expires. If you need to revoke access before expiration — because a user logs out, changes their password, or is suspended — you must either use short expiry times (15 minutes is common) and issue refresh tokens, or maintain a blocklist of revoked token IDs, which reintroduces the database lookup you were trying to avoid.

Algorithm confusion is a classic JWT vulnerability: some older libraries accept "alg": "none" in the header, allowing an attacker to strip the signature entirely. Always specify the algorithms you accept explicitly when calling your verification library. Similarly, verify iss, aud, and exp claims — do not trust a library that verifies the signature but skips claim validation.

OAuth 2.0: delegated authorization

OAuth 2.0 is not an authentication protocol — it is an authorization framework. Its purpose is to allow a user to grant a third-party application access to their resources on another service, without sharing their credentials with the third party. The "Sign in with Google" flow on a website is OAuth 2.0 (often combined with OpenID Connect for identity).

The key parties in OAuth 2.0:

  • Resource owner: the user who owns the data.
  • Client: the application requesting access.
  • Authorization server: the server that issues tokens (e.g., Google, GitHub).
  • Resource server: the API that holds the protected data.

The most common flow for web applications is the Authorization Code flow:

# Step 1: redirect the user to the authorization server
# Your app constructs this URL and redirects the browser to it
auth_url = (
    "https://accounts.google.com/o/oauth2/v2/auth"
    "?response_type=code"
    "&client_id=YOUR_CLIENT_ID"
    "&redirect_uri=https://yourapp.com/callback"
    "&scope=openid email profile"
    "&state=RANDOM_CSRF_TOKEN"
)

# Step 2: user authenticates with Google and approves access
# Google redirects back to your callback URL with a code:
# https://yourapp.com/callback?code=AUTH_CODE&state=RANDOM_CSRF_TOKEN

# Step 3: your server exchanges the code for tokens
import requests
token_response = requests.post("https://oauth2.googleapis.com/token", data={
    "code": auth_code,
    "client_id": CLIENT_ID,
    "client_secret": CLIENT_SECRET,
    "redirect_uri": "https://yourapp.com/callback",
    "grant_type": "authorization_code"
})
tokens = token_response.json()
# tokens contains: access_token, id_token, refresh_token, expires_in

The authorization code is short-lived and single-use. It is exchanged server-to-server (your backend to Google's token endpoint) so the actual tokens are never exposed in the browser URL or browser history. The state parameter is a CSRF token you must verify on callback to prevent an attacker from injecting their own authorization code.

For mobile and single-page apps, the Authorization Code flow with PKCE (Proof Key for Code Exchange) replaces the client secret with a code verifier/challenge pair, solving the problem that these clients cannot keep a secret securely.

The Client Credentials flow is OAuth 2.0 for machine-to-machine calls where there is no user involved. One server authenticates directly to the authorization server with its client ID and secret and receives an access token, similar to an API key but with OAuth's token format and expiry semantics.

Access tokens and refresh tokens

OAuth 2.0 issues two kinds of tokens. An access token is short-lived (typically 15 minutes to 1 hour) and is sent with every API request. A refresh token is long-lived (days to months) and is used only to obtain new access tokens when the current one expires.

This separation limits exposure: if an access token is leaked in a log or intercepted in transit, it expires quickly. The refresh token is sent far less often (only to the authorization server) and is therefore less likely to appear in logs or be intercepted. Refresh tokens should be stored securely (httpOnly cookies on the web, the device keychain on mobile) and rotated on each use.

# Using a refresh token to get a new access token
def refresh_access_token(refresh_token: str) -> str:
    response = requests.post(TOKEN_ENDPOINT, data={
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
    })
    if response.status_code != 200:
        raise AuthError("Refresh failed; user must re-authenticate")
    data = response.json()
    store_refresh_token(data.get("refresh_token", refresh_token))
    return data["access_token"]

Choosing the right mechanism

The decision tree is straightforward in most cases:

  • Server-to-server, internal API: API key or OAuth 2.0 Client Credentials flow. API keys are simpler; Client Credentials gives you expiring tokens and a standard revocation path.
  • Web app authenticating its own users: issue JWTs after login. Short-lived access tokens plus refresh tokens stored in httpOnly cookies. Do not put JWTs in localStorage — they are accessible to any JavaScript on the page.
  • Third-party access to a user's data: OAuth 2.0 Authorization Code flow. This is what "Login with GitHub" or "Connect your Dropbox" requires. You need it whenever the user is granting a separate application access to their account on your service.
  • Mobile app: Authorization Code flow with PKCE.

A common mistake is building a custom session token system when OAuth 2.0 with JWTs is all that is needed, or conversely using a full OAuth 2.0 authorization server (with its complexity and infrastructure requirements) for a simple internal API that API keys would serve adequately. Match the tool to the threat model and the user flow, not to what sounds most sophisticated.