Securing Your API Is More Than Just Adding a Token
by Eric Hanson, Backend Developer at Clean Systems Consulting
The checklist that stops at authentication
Most API security discussions cover authentication well: use OAuth, use JWTs, rotate your API keys. What gets less attention is everything that happens after a valid token arrives. Authentication tells you who is making the request. It says nothing about whether what they are doing is safe.
An authenticated attacker is still an attacker. They have a valid token — possibly stolen, possibly from a compromised account, possibly their own account used maliciously. Authentication being present does not mean authorization is correct, inputs are safe, data leakage is prevented, or denial-of-service is impossible.
Authorization: the layer authentication is not
Authentication and authorization are distinct. Authentication says "this is user 4291." Authorization says "user 4291 is allowed to access this resource in this way."
The two most common authorization failures in APIs are:
Broken Object Level Authorization (BOLA, also called IDOR): an authenticated user requests a resource they do not own by manipulating an ID parameter.
GET /api/v1/invoices/10042
Authorization: Bearer <valid_token_for_user_A>
If invoice 10042 belongs to user B, and the server returns it because the token is valid without checking ownership, that is BOLA. It is the most common API vulnerability class (OWASP API Security Top 10, #1).
The fix is at the data access layer, not the authentication layer:
def get_invoice(invoice_id: str, current_user: User) -> Invoice:
invoice = db.get(Invoice, invoice_id)
if invoice is None:
raise NotFound()
if invoice.owner_id != current_user.id:
raise Forbidden() # not NotFound — don't leak existence
return invoice
Never trust client-supplied IDs at face value. Always verify ownership against the authenticated identity.
Broken Function Level Authorization: endpoints that perform privileged operations are accessible to non-privileged users because the authorization check was missed.
This often happens when admin endpoints share a router with public endpoints and authorization middleware is only applied selectively. Apply authorization checks at the handler level as a default, not an opt-in.
Input validation is a security control
SQL injection, NoSQL injection, command injection, and XML injection are all input validation failures dressed up as security issues. The defense is treating all input as untrusted, regardless of whether the caller is authenticated.
Use parameterized queries — never string interpolation for database queries:
# Vulnerable
query = f"SELECT * FROM users WHERE email = '{email}'"
# Safe
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
Validate input shape and semantics before any processing. Reject requests that do not conform — do not sanitize and proceed. Sanitization is error-prone; rejection is robust.
Sensitive data in responses
Returning more data than necessary is a security failure. A user endpoint that returns hashed_password, internal_account_flags, or created_by_admin_id alongside the public profile is leaking data that clients have no business receiving.
Define response schemas explicitly and serialize only to that schema. In frameworks that support it (Spring, FastAPI, NestJS), use response serializer annotations or DTOs that explicitly whitelist returned fields rather than returning your entire domain object.
This also applies to error messages: never return stack traces, SQL errors, or internal system names in production API responses.
Transport security is not optional
All API traffic should be over TLS 1.2 minimum, TLS 1.3 preferred. Redirect HTTP to HTTPS at the load balancer layer. Set HSTS headers with a long max-age:
Strict-Transport-Security: max-age=31536000; includeSubDomains
For internal service-to-service traffic in a zero-trust environment, mTLS provides mutual authentication: both the client and server present certificates. This prevents a compromised internal service from calling other services using only a stolen token.
Rate limiting is security, not just operations
An unauthenticated endpoint with no rate limiting is a credential stuffing surface. A valid-token endpoint with no rate limiting is a data harvesting surface. Rate limiting at the API gateway level protects both.
Implement limits at multiple granularities: per-IP for unauthenticated endpoints, per-API-key or per-user for authenticated endpoints, and per-endpoint for sensitive operations like password reset or 2FA verification.
For authentication endpoints specifically, apply exponential backoff on failed attempts and alert on high failure rates from a single source.
Logging and monitoring as a security control
You cannot detect an attack you are not logging for. At minimum, log:
- Authentication failures with client IP and user agent
- Authorization failures with the authenticated identity, endpoint, and resource ID
- Abnormal request patterns (high volume from a single token, sequential ID enumeration)
- All requests to sensitive endpoints (admin functions, bulk exports, permission changes)
Do not log request bodies containing credentials or PII. Log the structure and outcome, not the sensitive content.
Set up alerts on authentication failure rate spikes, sudden increases in 403 responses from a previously normal client, and access to resources by IDs outside the expected range.
Security is not a state you achieve — it is a set of ongoing controls. Authentication is the first one, not the last.