Skip to main content

Command Palette

Search for a command to run...

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

Published
8 min read
Auth Is Not Security: What Engineers Get Wrong About Protecting APIs
A
Real-world engineering insights from 20+ years building scalable systems. Focused on AI, RAG architectures, and production-ready system design.

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?

Backend Engineering Fundamentals

Part 5 of 6

Backend systems don't fail because of bad code alone — they fail because of bad decisions. This series breaks down the foundational concepts every developer, architect, and engineer needs to build systems that scale, stay secure, and survive production: APIs, caching, security, databases, message queues, scalability, and observability. No fluff, no vendor pitches — just the tradeoffs that actually matter.

Up next

The API Decision That Haunts Your Architecture

Series: Backend Engineering Fundamentals · Post 01 of 07 Level: Intermediate · Read time: ~8 min A team I know spent nine months migrating their mobile backend from REST to GraphQL. Two engineers de