3 API Authentication Methods I Tested in Production — Here's What Actually Broke
Last year I built an API gateway that had to support three different authentication methods. I thought it would be straightforward — just pick the standard, right?
Wrong. Each method broke in ways I didn't expect. Here's what I learned, with real code examples so you can avoid the same headaches.
The Setup
I needed to authenticate three types of clients:
- End users logging into a web app
- Third-party developers calling our API from their servers
- Internal microservices talking to each other
The obvious choices: JWT for users, API Keys for third parties, OAuth2 for microservices. Simple.
Then production happened.
1. JWT: The Token That Wouldn't Expire
What Went Wrong
We issued JWTs with a 24-hour expiry. Standard stuff. But I forgot one thing: there's no built-in revocation.
A user reported their account was compromised. We changed their password, expecting their old tokens to stop working. They didn't. The JWT was still valid — it's just a signed blob of data, completely independent from the database.
The Fix
We added a token blacklist in Redis:
import redis
import jwt
from datetime import datetime
r = redis.Redis(host='localhost', port=6379, db=0)
def is_token_revoked(jti: str) -> bool:
"""Check if a JWT ID has been revoked."""
return r.exists(f"revoked:{jti}") > 0
def revoke_token(jti: str, exp: int):
"""Add token to blacklist until it naturally expires."""
ttl = exp - int(datetime.utcnow().timestamp())
if ttl > 0:
r.setex(f"revoked:{jti}", ttl, "1")
# Middleware check on every request
def verify_token(token: str):
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
if is_token_revoked(payload["jti"]):
raise InvalidTokenError("Token has been revoked")
return payload
The TTL trick is important — don't store revoked tokens forever. Set the Redis key to expire at the same time the JWT expires. No memory leak, no stale entries.
What I Wish Someone Told Me
- JWTs are stateless, but revocation makes them stateful. Factor that into your architecture from day one.
- Keep payloads small. Every extra field bloats every HTTP request. Put user roles in the JWT, but fetch permissions from the database.
- Use short-lived access tokens (15-30 min) with refresh tokens. This limits the damage window if a token leaks.
2. API Keys: The Invisible Leak
What Went Wrong
This one was embarrassing. A developer accidentally committed their API key to a public GitHub repo. By the time we noticed, someone had racked up significant unauthorized API usage over a weekend.
The Fix: Three Layers
Layer 1 — Rate Limits Per Key
from collections import defaultdict
import time
class RateLimiter:
def __init__(self):
self._windows = defaultdict(list)
def is_allowed(self, api_key: str, max_requests: int = 100, window: int = 60) -> bool:
now = time.time()
cutoff = now - window
self._windows[api_key] = [t for t in self._windows[api_key] if t > cutoff]
if len(self._windows[api_key]) >= max_requests:
return False
self._windows[api_key].append(now)
return True
Layer 2 — Usage Alerts
We set up a webhook that fires when a key exceeds 80% of its daily quota:
def check_usage_alert(api_key: str, usage: int, daily_limit: int = 10000):
ratio = usage / daily_limit
if 0.8 <= ratio < 1.0:
send_alert(f"Key ending in ...{api_key[-4:]} at {ratio:.0%} of daily limit")
elif usage >= daily_limit:
deactivate_key(api_key)
send_alert(f"Key EXCEEDED limit — DEACTIVATED")
Layer 3 — Key Rotation API
Let developers rotate their own keys without emailing you:
@app.post("/keys/{key_id}/rotate")
def rotate_api_key(key_id: str, user=Depends(get_current_user)):
old_key = db.get_key(key_id)
if old_key.owner_id != user.id:
raise HTTPException(403)
new_key = secrets.token_urlsafe(32)
db.update_key(key_id, new_key)
db.revoke_key(old_key.value)
return {"new_key": new_key, "old_key_expires": "24h"}
What I Wish Someone Told Me
- Never log API keys. Configure your logging framework to redact them automatically.
-
Prefix your keys (like
sk_live_xxx) so you can grep for leaks. GitHub's secret scanning also works better with recognizable patterns. - Don't use a single master key. Issue per-developer keys with different rate limits.
3. OAuth2 for Microservices: Overkill That Killed Performance
What Went Wrong
I used OAuth2 Client Credentials flow for internal service-to-service auth. Every call required a token exchange round-trip:
Service A → Auth Server: "Give me a token"
Auth Server → Service A: "Here's your token (valid 1 hour)"
Service A → Service B: "Here's my request + token"
Service B → Auth Server: "Is this token valid?"
Auth Server → Service B: "Yes"
Service B → Service A: "Here's your data"
Four network calls for one request. Latency jumped from 12ms to 85ms.
The Fix: mTLS + Signed JWTs
For internal traffic that never leaves the VPC, we replaced OAuth2 with mutual TLS:
# Service A — make a signed request
import httpx
import jwt
import time
import secrets
def call_service_b(endpoint: str, data: dict):
payload = {
"iss": "service-a",
"aud": "service-b",
"iat": int(time.time()),
"exp": int(time.time()) + 300,
"jti": secrets.token_hex(16)
}
token = jwt.encode(payload, SERVICE_A_PRIVATE_KEY, algorithm="RS256")
response = httpx.post(
f"https://service-b.internal/{endpoint}",
json=data,
headers={"Authorization": f"Bearer {token}"},
cert=("/etc/ssl/service-a.crt", "/etc/ssl/service-a.key"),
verify="/etc/ssl/ca.crt"
)
return response.json()
# Service B — verify with public key (no auth server call needed)
@app.post("/api/data")
def handle_request(request: Request):
token = request.headers["Authorization"].split(" ")[1]
try:
payload = jwt.decode(
token,
SERVICE_A_PUBLIC_KEY,
algorithms=["RS256"],
options={"require": ["iss", "exp"]}
)
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Token expired")
# Process request...
Latency dropped back to 14ms. The mTLS certs handle transport security, and the short-lived signed JWTs handle identity — no auth server needed.
What I Wish Someone Told Me
- mTLS + signed JWTs is the sweet spot for internal services. Certificates authenticate the service, JWTs authenticate the request.
- OAuth2 is great for third-party integrations, overkill for your own services. Don't use it internally unless you have a specific compliance requirement.
- Measure the overhead before committing. A quick benchmark on your proposed auth flow will save you weeks of "why is everything slow?"
The Decision Matrix
Here's how I now decide which auth method to use:
| Scenario | Recommendation | Why |
|---|---|---|
| User-facing web app | JWT + Refresh Tokens | Stateless, scalable, easy to invalidate with short TTL |
| Third-party API access | API Keys with HMAC | Easy for developers, scoped permissions, monitorable |
| Internal microservices | mTLS + Signed JWTs | Zero-auth-server latency, transport security built in |
| OAuth2 social login | OAuth2 (Authorization Code) | Industry standard, users already have Google/GitHub accounts |
| Mobile app | JWT + Refresh + Device Binding | Resist token theft with device fingerprint |
The Rule I Live By Now
The right auth method is the simplest one that meets your threat model.
Don't reach for OAuth2 because Google uses it. Don't use JWTs because they're trendy. Ask yourself:
- Who is calling my API? (Users? Servers? Both?)
- What happens if a credential leaks? (Financial loss? Data breach? Nothing?)
- Do I need to revoke access instantly? (If yes, JWT alone won't cut it)
- How many auth calls per second can I afford? (Every round-trip adds latency)
Answer those four questions honestly, and the right choice becomes obvious.
What auth horror stories do you have? I'd love to hear them in the comments — especially if you've dealt with SAML. I still have scars from that one.
Top comments (0)