Auth Is Not Security: What Engineers Get Wrong About Protecting APIs

Series: Backend Engineering Fundamentals · Post 03 of 07 Level: Advanced · Read time: ~10 min
Most API security bugs aren't cryptography failures. They're design failures.
The OWASP API Security Top 10 is the most authoritative list of real-world API vulnerabilities. It is dominated by problems like broken object-level authorization, excessive data exposure, and lack of rate limiting. Not broken TLS. Not weak encryption algorithms.
Engineers tend to conflate authentication ("who are you?") with security ("what can you actually do and what can go wrong?"). This post covers both. The auth patterns engineers deal with daily, and the security concerns that don't get enough attention until after the breach.
Authentication vs Authorization — Get This Right First
These terms are often used interchangeably. They shouldn't be.
| Concept | Question | Example |
|---|---|---|
| Authentication (AuthN) | Who are you? | Verifying a JWT token is valid |
| Authorization (AuthZ) | What are you allowed to do? | Checking if user can access /orders/456 |
| Accounting | What did you do? | Audit logs of actions taken |
Most auth bugs are authorization bugs. The token is valid — the user is who they say they are — but they can see data they shouldn't.
The Three Main Auth Patterns
1. API Keys — Simple, Durable, Underrated
A random string issued to a client, sent with every request.
GET /api/v1/orders
Authorization: Bearer sk_live_a8f3j2k9...
# or
X-API-Key: sk_live_a8f3j2k9...
Best for: Server-to-server communication, developer-facing public APIs, internal service authentication.
Key implementation details:
- Store only the hash of the key in your database, never the plaintext (same principle as passwords)
- Use a prefix that identifies the key type:
sk_live_,pk_test_,svc_— makes secret scanning easier - Support key rotation without downtime: allow two active keys per client during a rotation window
- Log key usage for anomaly detection
import hashlib, secrets
def create_api_key() -> tuple[str, str]:
"""Returns (plaintext_key_shown_once, hash_stored_in_db)"""
key = f"sk_live_{secrets.token_urlsafe(32)}"
key_hash = hashlib.sha256(key.encode()).hexdigest()
return key, key_hash
def verify_api_key(provided_key: str, stored_hash: str) -> bool:
provided_hash = hashlib.sha256(provided_key.encode()).hexdigest()
return secrets.compare_digest(provided_hash, stored_hash)
# Use compare_digest to prevent timing attacks
2. JWT (JSON Web Tokens) — Powerful but Frequently Misused
A JWT is a self-contained token with three parts: header, payload, signature.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Header (alg + type)
.eyJ1c2VySWQiOiIxMjMiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MDAwMDAwMDB9 ← Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
The server can verify the signature without a database lookup — this is why JWTs are popular in distributed systems and microservices.
Common JWT pitfalls:
# ❌ WRONG: Accepting the "none" algorithm
# An attacker can craft a token with alg: "none" and no signature
jwt.decode(token, options={"verify_signature": False}) # Never do this
# ❌ WRONG: Using the algorithm from the token header
# Attacker changes alg to "none" or "HS256" with your public key as secret
algorithm = jwt.get_unverified_header(token)['alg'] # Never trust this
# ✅ CORRECT: Always specify the expected algorithm explicitly
jwt.decode(token, secret_key, algorithms=["RS256"]) # Whitelist the algorithm
# ❌ WRONG: Storing sensitive data in the payload
# JWT payload is base64-encoded, not encrypted — anyone can read it
{"userId": "123", "creditCardNumber": "4111..."} # Don't do this
# ✅ CORRECT: Store only what's needed for authorization
{"userId": "123", "role": "admin", "exp": 1700000000}
JWT vs Sessions trade-off:
| JWT (Stateless) | Sessions (Stateful) | |
|---|---|---|
| Revocation | Hard — must wait for expiry or maintain a blocklist | Easy — delete from session store |
| Scalability | Any server can verify without coordination | Session store must be shared (Redis) |
| Token size | Larger (full claims in payload) | Smaller (just a session ID) |
| Suitable for | Microservices, mobile APIs | Traditional web apps |
⚠️ The revocation problem is real. If you issue a JWT with a 24-hour expiry and a user changes their password or is suspended, that JWT is still valid until it expires. If revocation matters to you (it usually does), maintain a JWT blocklist in Redis or use short expiry times (5–15 minutes) with refresh tokens.
3. OAuth 2.0 — Delegated Authorization Done Right
OAuth 2.0 is not an authentication protocol (that's OpenID Connect on top of OAuth). It's a framework for delegated authorization — letting users grant third-party apps access to their data without sharing their password.
The four flows, matched to use cases:
Authorization Code Flow
├── With PKCE (for SPAs, mobile apps)
└── Without PKCE (server-side web apps only — never expose client_secret in browser)
Client Credentials Flow
└── Machine-to-machine (no user involved)
Device Authorization Flow
└── Smart TVs, CLIs, IoT devices
Implicit Flow
└── ⚠️ Deprecated — never use for new implementations
Most teams only need two:
User-facing apps → Authorization Code + PKCE
Service-to-service → Client Credentials
# Client Credentials — Service authenticating to another service
import httpx
def get_service_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": "orders:read inventory:write"
})
return response.json()["access_token"]
OWASP API Security Top 10 — What Actually Gets APIs Breached
Authentication is one piece. Here are a few vulnerabilities that show up in real incidents:
Broken Object-Level Authorization (BOLA) (Most Common)
A user can access objects (records) they shouldn't by manipulating IDs.
GET /api/orders/12345 ← User's own order
GET /api/orders/12346 ← Another user's order — does your API check ownership?
# ❌ WRONG — only checks authentication, not authorization
@app.get("/orders/{order_id}")
def get_order(order_id: str, current_user: User = Depends(get_current_user)):
return db.get_order(order_id) # Returns ANY order if user is authenticated
# ✅ CORRECT — checks that the order belongs to the requesting user
@app.get("/orders/{order_id}")
def get_order(order_id: str, current_user: User = Depends(get_current_user)):
order = db.get_order(order_id)
if order.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return order
Excessive Data Exposure
Returning more data than the client needs, relying on them to filter it.
# ❌ WRONG — serializes the full User model
return db.get_user(user_id)
# Includes: password_hash, internal_notes, admin_flags, ...
# ✅ CORRECT — explicit response schema
class UserPublicResponse(BaseModel):
id: str
name: str
email: str
# Nothing else
Lack of Rate Limiting
Without rate limiting, your API is vulnerable to brute-force, credential stuffing, and scraping.
# Using a token bucket approach with Redis
def check_rate_limit(client_id: str, limit: int = 100, window: int = 60) -> bool:
key = f"rate_limit:{client_id}"
pipe = redis.pipeline()
pipe.incr(key)
pipe.expire(key, window)
count, _ = pipe.execute()
return count <= limit
Or at the infrastructure level with NGINX:
# Limit to 10 requests/second per IP
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
}
}
HTTPS, HSTS, and Transport Security
HTTPS should be non-negotiable. But securing transport is not just about turning on TLS. HSTS tells the browser to always use HTTPS and never fall back to HTTP. A few headers help close common gaps:
# Force HTTPS for your domain + subdomains, 1 year, include in preload list
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# Prevent MIME type sniffing
X-Content-Type-Options: nosniff
# Control what info leaks in the Referer header
Referrer-Policy: strict-origin-when-cross-origin
# Disable browser features you don't need
Permissions-Policy: geolocation=(), camera=(), microphone=()
Secrets Management — Where Most Teams Cut Corners
Hardcoded secrets are the most preventable security vulnerability.
# ❌ Hardcoded in code — will end up in git history eventually
DATABASE_URL = "postgresql://admin:mypassword@prod-db:5432/app"
# ❌ In .env committed to repo
echo ".env" >> .gitignore # This gets forgotten
# ✅ Fetched from a secrets manager at runtime
import boto3
def get_secret(secret_name: str) -> str:
client = boto3.client("secretsmanager")
return client.get_secret_value(SecretId=secret_name)["SecretString"]
DATABASE_URL = get_secret("prod/database/url")
Use AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, or Azure Key Vault. The investment is low; the blast radius of a leaked secret can be catastrophic.
Security Checklist for APIs
Before shipping an API endpoint, run through this:
- Authentication required on all non-public routes
- Object-level authorization: does the user own this resource?
- Response schema is explicit — no extra fields leaking
- Rate limiting on auth endpoints (login, token issuance)
- Rate limiting on resource endpoints
- Input validation on all parameters (types, lengths, allowed values)
- No sensitive data in JWT payload
- API keys hashed in storage, never in logs
- HTTPS enforced with HSTS header
- Secrets loaded from a secrets manager, not env vars in code
Key Takeaways
- Authentication ≠ Authorization — most breaches happen when you verify identity but don't verify permission
- API Keys are underrated for server-to-server auth — hash them, support rotation, prefix for scanning
- JWT pitfalls (none algorithm, payload exposure, no revocation) are more common than you'd think
- OAuth 2.0: Authorization Code + PKCE for users, Client Credentials for services — that's most of what you need
- BOLA (broken object-level auth) is the #1 real-world API vulnerability — always check resource ownership
- Rate limiting and secrets management are table stakes, not nice-to-haves
What's the most memorable security incident you've seen or heard about that started with an API design mistake — not a cryptography failure?
Next in the series → Post 04: SQL or NoSQL? Wrong Question. Here's the Right One.
You know who's talking to your API and what they're allowed to do. Now: where does the data actually live?




