Quick take
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.
At the fintech startup we serve financial data to institutional and retail clients through a REST API. Financial data has a specific property that makes API security non-negotiable: it moves markets. A leaked endpoint, an expired token that still works, a missing authorization check on a premium feed – any of these is a compliance incident, not just a bug.
I’ve spent the last year hardening our 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.
At the fintech startup we’ve free-tier users, premium subscribers, and institutional clients. All three authenticate the same way. But a free-tier token must not grant access to real-time institutional feeds. That’s authorization, and it needs its own enforcement layer.
JWT: the right tool, used badly by most teams
JWT became the standard for stateless API auth in 2016, 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 run at the fintech startup. Every request hits this before touching a route handler:
import jwt
from functools import wraps
from flask import request, jsonify
EXPECTED_ISSUER = "auth.fintech-app.com"
EXPECTED_AUDIENCE = "api.fintech-app.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", "sub", "iss", "aud", "tier"]}
)
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token expired"}), 401
except jwt.InvalidTokenError:
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 attack vector, not theoretical.
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 financial 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:news # Free-tier news content
read:sentiment # Sentiment scores (premium)
read:realtime # Real-time feeds (institutional)
read:watchlists # User watchlist data
write:watchlists # Modify user watchlists
Scopes map directly to subscription tiers. A partner application authorized by a free-tier user physically 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, "institutional": 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/<ticker>")
@require_auth
@require_tier("institutional")
def get_realtime_feed(ticker):
# Only reaches here if token is valid AND user is institutional tier
return fetch_realtime_data(ticker)
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/portfolio 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 the single 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 financial data. It happens more than anyone admits.
What I would do differently
If I were starting the fintech startup’s API 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.
Security isn’t a feature you ship once. It’s a discipline you maintain every release.