Writing / 2016
Securing APIs: Authentication and Authorization Patterns
How we secured a tiered data API: short-lived RS256 JWTs, OAuth2 scopes mapped to tiers, fail-closed authorization middleware, safe token storage.
JWT with short-lived tokens, OAuth2 authorization code flow, and middleware that fails closed. If you get nothing else from this post, validate every claim on every request and never trust the client.
One system I worked on served data to paying and free-tier clients through a REST API . Paid access tiers make API security non-negotiable. A leaked endpoint, an expired token that still works, a missing authorization check on a premium feed: any of these is a security incident, not just a bug.
I spent a long stretch hardening that API layer. Here is what I learned, with actual code.
Authentication isn’t authorization
This distinction matters more than it sounds. Authentication answers “who are you?” Authorization answers “what can you access?” Most API security failures happen because teams conflate the two or implement one without the other.
That API had free-tier users, premium subscribers, and enterprise clients. All three authenticate the same way. But a free-tier token must not grant access to real-time enterprise feeds. That’s authorization, and it needs its own enforcement layer.
JWT: the right tool, used badly by most teams
JWT (standardized as RFC 7519 in 2015) has become the default for stateless API auth, and for good reason. No session store. No sticky sessions. Horizontally scalable verification. But most JWT implementations I review have the same problems:
- No expiration or absurdly long expiration (days, weeks)
- Algorithm set to
noneaccepted - Claims not validated beyond signature
- Tokens stored in localStorage with no XSS protection
Here is the token validation middleware we ran on it. Every request hits this before touching a route handler:
import jwt
from functools import wraps
from flask import request, jsonify
EXPECTED_ISSUER = "auth.example.com"
EXPECTED_AUDIENCE = "api.example.com"
def require_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get("Authorization", "").replace("Bearer ", "")
if not token:
return jsonify({"error": "Missing token"}), 401
try:
payload = jwt.decode(
token,
PUBLIC_KEY,
algorithms=["RS256"], # Explicit. Never allow 'none'.
issuer=EXPECTED_ISSUER,
audience=EXPECTED_AUDIENCE,
options={"require_exp": True}, # iss/aud enforced by the args above
)
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token expired"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "Invalid token"}), 401
# PyJWT only knows how to require exp/iat/nbf; check the rest here
if "sub" not in payload or "tier" not in payload:
return jsonify({"error": "Invalid token"}), 401
request.current_user = payload
return f(*args, **kwargs)
return decorated
A few things to notice.
RS256, not HS256. Asymmetric signing means the API servers only need the public key. If an API server is compromised, the attacker can’t forge tokens. The private key lives on the auth server and nowhere else.
Explicit algorithm list. The algorithms=["RS256"] parameter isn’t optional. Without it, an attacker can send a token signed with none or switch to HS256 using the public key as the secret. This is a real, publicly documented attack.
Required claims. We require exp, sub, iss, aud, and a custom tier claim. If any are missing, the token is rejected. The tier claim feeds directly into authorization.
Short-lived tokens. Our access tokens expire in 15 minutes. Refresh tokens last 7 days and are stored server-side with revocation capability. Yes, this means more refresh calls. The trade-off is worth it.
OAuth2 for third-party access
We use OAuth2 authorization code flow for partners who integrate our data into their platforms. Client credentials flow for server-to-server. Never implicit flow: it puts tokens in URLs and browser history.
The authorization code flow matters because it keeps the user’s credentials away from the third party entirely. The partner application never sees a password. They get an authorization code, exchange it for tokens server-side, and use those tokens with scoped permissions.
Our scopes are specific:
read:content # Free-tier content
read:enriched # Enriched data (premium)
read:realtime # Real-time feeds (enterprise)
read:lists # Saved user lists
write:lists # Modify saved user lists
Scopes map directly to subscription tiers. A partner application authorized by a free-tier user can’t request read:realtime. The authorization server rejects it before a token is ever issued.
Authorization middleware that fails closed
Authentication middleware says “this is a valid user.” Authorization middleware says “this user can do this specific thing.” They are separate layers.
def require_tier(minimum_tier):
"""Enforce subscription tier on a route."""
TIER_LEVELS = {"free": 0, "premium": 1, "enterprise": 2}
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
user_tier = request.current_user.get("tier")
if user_tier is None:
return jsonify({"error": "Forbidden"}), 403
if TIER_LEVELS.get(user_tier, -1) < TIER_LEVELS[minimum_tier]:
return jsonify({"error": "Upgrade required"}), 403
return f(*args, **kwargs)
return decorated
return decorator
@app.route("/api/v1/realtime/<feed_id>")
@require_auth
@require_tier("enterprise")
def get_realtime_feed(feed_id):
# Only reaches here if token is valid AND user is enterprise tier
return fetch_realtime_data(feed_id)
Notice the get with a default of -1 for unknown tiers. If somehow a token arrives with tier: "admin" or any value we don’t recognize, it maps to -1 and is denied. Fail closed. Always.
The mistakes I see repeatedly
Checking authorization in the route handler. Authorization logic scattered across dozens of route handlers is authorization logic that will be forgotten in at least one handler. Use middleware or decorators. Make it declarative.
Returning 403 for resources that should 404. If a user requests /api/users/12345/profile and they aren’t user 12345, returning 403 confirms that user 12345 exists. Return 404. Don’t leak information about your data model through error codes.
No rate limiting on token endpoints. Your /auth/token endpoint is likely the most attacked endpoint in your API. Rate limit it aggressively. We do 5 attempts per minute per IP, with exponential backoff after 3 failures.
Logging tokens in access logs. If your Authorization header shows up in your access logs, you have a credential leak in your log infrastructure. Strip or mask tokens before logging. This sounds obvious until you realize your load balancer, CDN, or API gateway might be logging full headers by default.
Token storage on the client
For web clients: httpOnly, secure, sameSite cookies. Not localStorage. Not sessionStorage. An XSS vulnerability with localStorage token storage gives the attacker full API access that persists after the session ends. With httpOnly cookies, XSS can’t read the token at all.
For mobile clients: use the platform keychain (iOS Keychain, Android Keystore). Not SharedPreferences. Not NSUserDefaults.
For server-to-server: environment variables or a secrets manager. Not config files committed to version control . I’ve seen API keys in public GitHub repos that granted access to production customer data. It happens more than anyone admits.
What I would do differently
If I were starting that API’s auth from scratch today, I would use asymmetric JWT from day one instead of migrating from symmetric later. I would implement token revocation lists backed by Redis from the start, rather than bolting it on. And I would build the scope system before the first external partner integration, not during it.
The fundamentals don’t change though. Short-lived tokens. Explicit algorithm validation. Authorization as a separate middleware layer. Fail closed on every ambiguous case. Log everything except the credentials themselves.