Protecting APIs Against Common Attacks
Production APIs are exposed to more than malformed requests. Attackers can manipulate identifiers, inject untrusted input into downstream systems, abuse authentication flows, force expensive application paths, exploit unsafe URL fetching, or overwhelm shared resources with requests that are technically valid.
Protecting an API therefore requires more than authentication and a web application firewall. Defensive controls must exist at several layers: the edge filters obvious abuse, the gateway limits and authenticates traffic, the application validates business input and authorization, and downstream systems enforce their own least-privilege boundaries.
The practical goal is not to recognize every possible attack string. It is to design APIs so that untrusted input has limited influence, identities cannot cross authorization boundaries, expensive resources are bounded, and compromised credentials have a small blast radius.
Table of Contents
- Understanding the API Attack Surface
- Broken Access Control and Resource Enumeration
- Injection Attacks
- Server-Side Request Forgery
- Authentication and Credential Attacks
- Resource Exhaustion and API Abuse
- Mass Assignment and Excessive Data Exposure
- Security Failure Scenarios
- Production Design Example
- Ready-to-Use Example
- Common Mistakes
- Production Checklist
- Conclusion
- More Articles to Read
Understanding the API Attack Surface
An API attack surface includes every place where an untrusted client can influence execution, data access, resource consumption, or downstream communication.
Client
|
v
Headers
Query Parameters
Path Parameters
Request Body
Uploaded Files
|
v
API Gateway
|
v
Application Logic
|
+--> Database
|
+--> Cache
|
+--> Message Queue
|
+--> Filesystem
|
+--> External API
|
+--> Internal Network
The security risk is determined not only by what the input contains but by where that input is eventually used.
For example:
user input
|
+--> SQL query
|
+--> shell command
|
+--> URL fetch
|
+--> object property update
|
+--> file path
Each sink has different defensive requirements.
| Attack Class | Typical Entry Point | Main Defense |
|---|---|---|
| Broken access control | Resource identifiers | Resource-level authorization |
| SQL injection | Query or body values | Parameterized SQL |
| SSRF | User-controlled URLs | Destination allowlists and network isolation |
| Credential attacks | Login and token endpoints | Rate limits and anomaly detection |
| Resource exhaustion | Expensive valid requests | Quotas, concurrency limits, timeouts |
| Mass assignment | JSON objects | Explicit request schemas |
| Data exposure | API responses | Explicit response schemas |
The strongest architecture prevents dangerous interactions by construction rather than depending entirely on filters that attempt to recognize malicious strings.
Broken Access Control and Resource Enumeration
One of the most damaging API vulnerabilities occurs when an authenticated user can access another user's or tenant's resources by changing an identifier.
Consider:
GET /shipments/481
An attacker changes the identifier:
GET /shipments/482
GET /shipments/483
GET /shipments/484
If the API only verifies that the caller is authenticated, another tenant's shipment may be returned.
This is insecure:
shipment = await repository.get(
shipment_id
)
return shipment
The query should include the trusted tenant boundary:
SELECT
id,
account_id,
status,
created_at
FROM shipments
WHERE id = $1
AND account_id = $2
LIMIT 1;
Now a resource identifier alone is insufficient to obtain another tenant's object.
The authorization flow becomes:
Authenticated Identity
|
v
account_id = 82
|
v
GET /shipments/481
|
v
Query:
id = 481
AND account_id = 82
|
+--> found --> return
|
+--> not found --> 404
Returning a consistent 404 for inaccessible resources can also reduce resource enumeration by avoiding confirmation that a foreign object exists.
Access-control checks should cover indirect resources as well:
Shipment
|
+--> Label
|
+--> Document
|
+--> Tracking History
A label endpoint should not assume that possession of a label ID proves authorization. Ownership should still be derived through the protected business resource.
Advantages of tenant-scoped data access:
- authorization becomes part of the query path;
- accidentally omitted ownership checks become less likely;
- indexes can support tenant-first access efficiently;
- foreign resource existence can remain hidden.
Disadvantages:
- repository APIs must consistently require tenant context;
- administrative cross-tenant use cases need explicit separate paths;
- complex ownership models may require additional joins or policy evaluation.
The broader relationship between identity and resource permissions is covered in Authentication vs Authorization.
Injection Attacks
Injection occurs when untrusted input changes the meaning of instructions interpreted by another subsystem. The correct defense depends on the interpreter involved.
The most important pattern is:
Untrusted Data
!=
Executable Instructions
Input validation is useful, but safe APIs should prefer interfaces that structurally separate data from executable syntax.
SQL Injection
Building SQL through string interpolation is dangerous:
query = (
"SELECT id, status "
"FROM shipments "
f"WHERE tracking_number = '{tracking_number}'"
)
A malicious value can alter the SQL statement instead of remaining data.
Use parameterized queries:
query = """
SELECT
id,
status
FROM shipments
WHERE account_id = %s
AND tracking_number = %s
"""
cursor.execute(
query,
(
account_id,
tracking_number,
),
)
The database driver sends the query structure and values separately.
Validation should still constrain business input:
from pydantic import BaseModel, Field
class TrackingRequest(BaseModel):
tracking_number: str = Field(
min_length=5,
max_length=64,
pattern=r"^[A-Za-z0-9\-]+$",
)
But validation and parameterization solve different problems. Parameterization protects query structure, while validation protects business assumptions and resource usage.
Command and Template Injection
Similar risks exist when user input is passed into shell commands, templates, or interpreters.
This is dangerous:
import os
os.system(
f"convert {uploaded_file} output.png"
)
Prefer APIs that avoid invoking a shell entirely:
import subprocess
subprocess.run(
[
"convert",
uploaded_file,
"output.png",
],
check=True,
timeout=10,
)
Even this still requires trusted executable selection, file validation, timeouts, resource limits, and safe filesystem handling.
The same rule applies to templates. User-controlled values should be rendered as data rather than interpreted as template source.
Server-Side Request Forgery
Server-Side Request Forgery occurs when an attacker causes the API server to make network requests to destinations chosen by the attacker.
Consider an image-import endpoint:
POST /images/import
{
"url": "https://example.com/image.png"
}
If the application blindly fetches the supplied URL, an attacker may attempt:
http://127.0.0.1/admin
http://localhost/internal
http://10.0.0.12/private-api
http://169.254.169.254/...
The server may have access to networks that the attacker cannot reach directly.
Attacker
|
| cannot access
X----------------> Internal Service
Attacker
|
v
Public API
|
| server-side request
v
Internal Service
Defenses should combine application and network controls.
Application controls can include:
- allowing only specific schemes;
- allowlisting approved destination domains where practical;
- rejecting loopback, private, link-local, and metadata addresses;
- limiting redirects;
- revalidating destinations after DNS resolution;
- limiting response size and request duration.
Network architecture should additionally prevent application workloads from reaching infrastructure they do not require.
Image Import Service
|
+--> Internet HTTPS
|
X--> Metadata Endpoint
|
X--> Administrative Network
|
X--> Database Network
This provides protection even if application-level URL validation has a bug.
When user-supplied URLs are required: isolate the fetching function into a narrowly privileged service with strict outbound network rules, bounded downloads, and no access to sensitive internal systems.
Authentication and Credential Attacks
Authentication endpoints attract automated attacks because successful credential compromise gives the attacker legitimate-looking access.
Common patterns include:
Password guessing
Credential stuffing
API key enumeration
Refresh token theft
Session token reuse
Automated account discovery
A login endpoint should have stronger protections than an ordinary authenticated read endpoint.
Login Request
|
v
Edge Rate Limit
|
v
Per-IP Limit
|
v
Per-Account Limit
|
v
Credential Validation
|
v
Risk / Abuse Signals
|
v
Authentication Result
Rate limiting only by username is insufficient because attackers can distribute attempts across accounts. Limiting only by IP is also insufficient because attacks can originate from large distributed networks.
Useful signals include:
failures per IP
failures per account
unique accounts per IP
unique IPs per account
success after many failures
geographic anomalies
token refresh anomalies
API keys should be independently revocable and narrowly scoped. One leaked key should not require rotating every customer integration.
Bearer tokens should remain short-lived enough that accidental exposure has a bounded lifetime. Refresh credentials require stronger storage because they can mint new access tokens.
Credential architecture is discussed in OAuth 2.0 vs JWT vs API Keys.
Resource Exhaustion and API Abuse
Many denial-of-service scenarios use requests that are completely valid. The attacker simply chooses operations whose cost is much larger than the cost of sending the request.
Consider a rating API:
POST /rates
|
+--> Carrier A
+--> Carrier B
+--> Carrier C
+--> Carrier D
+--> Carrier E
One incoming request creates five external requests.
If an attacker sends:
2,000 requests/sec
the platform may generate:
2,000 × 5
= 10,000 carrier requests/sec
This is a request-amplification problem.
Protection should exist at multiple layers:
Global Limit
|
v
Tenant Quota
|
v
Endpoint Limit
|
v
Concurrency Limit
|
v
Dependency Limit
|
v
Timeout
Different limits protect different resources.
| Control | Protects | Example |
|---|---|---|
| Request rate limit | API throughput | 500 requests/minute per tenant |
| Concurrency limit | Workers and dependencies | 20 active rating requests per tenant |
| Pagination limit | Database and response size | Maximum 100 rows |
| Body limit | Memory and bandwidth | 1 MB JSON limit |
| Timeout | Occupied workers | 2-second carrier deadline |
| Dependency bulkhead | Downstream service | 100 concurrent calls per carrier |
Pagination is especially important for database-backed APIs.
This is dangerous:
GET /shipments?limit=1000000
The API should enforce server-side bounds regardless of the requested value:
from pydantic import BaseModel, Field
class Pagination(BaseModel):
page: int = Field(
ge=1,
)
size: int = Field(
ge=1,
le=100,
)
Resource controls should also be observable. A sudden rise in rate-limit rejections or concurrency saturation can indicate either abuse or legitimate capacity growth.
Mass Assignment and Excessive Data Exposure
Mass assignment occurs when arbitrary client fields are bound directly to internal application or database models.
Suppose an account model contains:
{
"name": "Example",
"email": "user@example.com",
"role": "user",
"is_admin": false,
"credit_limit": 5000
}
If the update endpoint accepts every model field automatically, a client may attempt:
{
"name": "Example",
"is_admin": true,
"credit_limit": 1000000
}
Instead, request contracts should define exactly which fields are writable:
from pydantic import BaseModel, Field
class UpdateAccountRequest(BaseModel):
name: str = Field(
min_length=1,
max_length=120,
)
The same principle applies to responses.
This pattern is risky:
Database Entity
|
v
Serialize Entire Object
|
v
API Response
Internal fields added later may silently become externally visible.
Prefer:
from pydantic import BaseModel
class ShipmentResponse(BaseModel):
id: int
status: str
tracking_number: str | None
Now the API contract explicitly determines what leaves the service.
This provides defense against both accidental privilege modification and accidental data exposure.
Security Failure Scenarios
Security controls need defined failure behavior. A defensive component that becomes unavailable can otherwise create either an application outage or an unexpected security bypass.
Rate-limit backend unavailable.
Distributed Limiter
X
unavailable
|
v
Local emergency quota
|
v
Reduced traffic allowed
|
v
Operations alerted
A conservative fallback can preserve limited availability without allowing unlimited traffic.
Authorization dependency unavailable. High-risk writes should normally fail closed. Low-risk operations may use carefully bounded cached decisions only if stale authorization is acceptable.
WAF unavailable or bypassed. Application validation and authorization must remain effective. Edge protection should never be the only control preventing injection or cross-tenant access.
Credential leaked. Individual revocation, narrow scopes, quotas, and short lifetimes reduce the blast radius.
Application node compromised. Least-privilege cloud roles, network segmentation, and separate service credentials prevent one compromised API process from becoming equivalent to the entire production environment.
External dependency becomes slow. An attacker can sometimes exploit a normally valid endpoint to hold resources open. Dependency timeouts and concurrency bulkheads should prevent one slow downstream system from consuming the entire worker pool.
Logging unavailable. Application availability should not normally depend synchronously on remote security logging. Events can be buffered or queued, while dropped audit telemetry should itself generate an operational signal.
Attack causes telemetry explosion. Repeated malicious requests can generate enormous logs. Security event aggregation and rate control should preserve useful evidence without allowing telemetry cost to become another denial-of-service vector.
Production Design Example
Consider a multi-tenant logistics platform exposing shipment, tracking, rate-shopping, and document APIs.
Internet
|
v
CDN / DDoS
|
v
WAF
|
v
API Gateway
/ | \
v v v
AuthN Rate Limit Size Limits
\ | /
\ | /
v v v
Shipment API
/ | \
v v v
PostgreSQL Redis Booking Queue
|
v
Booking Workers
|
+----+----+
| |
v v
Carrier A Carrier B
Attack 1: cross-tenant enumeration.
An authenticated attacker from account 82 requests sequential shipment IDs.
GET /shipments/1001
GET /shipments/1002
GET /shipments/1003
The repository always applies:
WHERE id = $1
AND account_id = $2
Foreign resources return the same not-found response as nonexistent resources. Authorization-denial telemetry detects unusual enumeration patterns without exposing foreign IDs.
Attack 2: SQL injection attempt.
The attacker sends a crafted tracking number. Request validation rejects structurally invalid values, while parameterized SQL ensures accepted values cannot alter query syntax.
Input Validation
|
v
Parameterized Query
|
v
Tenant Scope
|
v
Database
Multiple controls therefore protect the same data boundary.
Attack 3: rate-shopping abuse.
One rate request fans out to five carriers.
Client Request
|
v
Tenant Quota
|
v
Concurrent Request Limit
|
v
Rating Service
|
+--> Carrier A bulkhead
+--> Carrier B bulkhead
+--> Carrier C bulkhead
+--> Carrier D bulkhead
+--> Carrier E bulkhead
An attacker cannot consume unlimited carrier concurrency even with a valid customer credential.
Attack 4: SSRF through document import.
The platform allows importing commercial documents from approved external storage domains.
User URL
|
v
Parse
|
v
HTTPS only?
|
v
Approved host?
|
v
Resolve destination
|
v
Public address?
|
v
Fetch through isolated service
The fetching service has no route to production databases, instance metadata, or administrative networks.
Attack 5: leaked integration key.
The compromised key belongs only to account 82 and has:
scopes:
shipments:read
quota:
300 requests/minute
The attacker cannot create shipments or access other accounts. The key can be independently revoked without affecting other integrations.
Monitoring. Security dashboards correlate WAF blocks, authentication failures, authorization denials, throttled requests, suspicious URL imports, SQL errors, queue growth, and dependency concurrency saturation.
Scaling. Cheap edge controls reject obvious abuse before application processing. Tenant quotas and dependency bulkheads protect internal capacity independently of API instance count.
Deployment. Negative security tests run during deployment to verify cross-tenant access, unauthorized writes, oversized payloads, and invalid authentication remain rejected.
Ready-to-Use Example
A FastAPI application can combine explicit request validation, trusted identity, tenant-scoped access, and server-controlled pagination.
from dataclasses import dataclass
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Query, status
app = FastAPI()
@dataclass(frozen=True)
class Identity:
subject: str
account_id: int
scopes: frozenset[str]
async def authenticated_identity() -> Identity:
return Identity(
subject="user-481",
account_id=82,
scopes=frozenset({
"shipments:read",
}),
)
def require_scope(
identity: Identity,
scope: str,
) -> None:
if scope not in identity.scopes:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions",
)
The endpoint never accepts tenant ownership from the client:
@app.get("/shipments")
async def list_shipments(
identity: Annotated[
Identity,
Depends(authenticated_identity),
],
page: Annotated[
int,
Query(ge=1),
] = 1,
size: Annotated[
int,
Query(ge=1, le=100),
] = 20,
) -> list[dict[str, object]]:
require_scope(
identity,
"shipments:read",
)
shipments = await shipment_repository.find_for_account(
account_id=identity.account_id,
page=page,
size=size,
)
return [
{
"id": shipment.id,
"status": shipment.status,
"tracking_number": shipment.tracking_number,
}
for shipment in shipments
]
The SQL remains tenant-scoped and parameterized:
SELECT
id,
status,
tracking_number,
created_at
FROM shipments
WHERE account_id = $1
ORDER BY id DESC
LIMIT $2
OFFSET $3;
For URL-import functionality, destination validation should be explicit:
import ipaddress
import socket
from dataclasses import dataclass
from urllib.parse import urlparse
@dataclass(frozen=True)
class ImportTarget:
host: str
url: str
ALLOWED_HOSTS = {
"documents.example-cdn.com",
"customer-import.example.com",
}
def validate_import_url(
raw_url: str,
) -> ImportTarget:
parsed = urlparse(raw_url)
if parsed.scheme != "https":
raise ValueError(
"Only HTTPS URLs are allowed"
)
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError(
"Host is not allowed"
)
addresses = socket.getaddrinfo(
parsed.hostname,
443,
type=socket.SOCK_STREAM,
)
for address in addresses:
ip = ipaddress.ip_address(
address[4][0]
)
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
):
raise ValueError(
"Destination address is not allowed"
)
return ImportTarget(
host=parsed.hostname,
url=raw_url,
)
This check should still be complemented by outbound network restrictions because application validation alone cannot provide a complete SSRF boundary.
Mass assignment can be prevented with explicit request models:
from pydantic import BaseModel, Field
class UpdateProfileRequest(BaseModel):
display_name: str = Field(
min_length=1,
max_length=80,
)
phone: str | None = Field(
default=None,
max_length=32,
)
Fields such as role, is_admin, account_id, and credit_limit are not writable because they are not part of the public request contract.
A database update should explicitly list writable fields:
UPDATE users
SET
display_name = $1,
phone = $2
WHERE id = $3
AND account_id = $4
RETURNING id;
Security events should capture the decision without recording credentials:
import json
import logging
from dataclasses import asdict, dataclass
logger = logging.getLogger("security")
@dataclass(frozen=True)
class SecurityEvent:
event: str
subject: str | None
account_id: int | None
operation: str
result: str
reason: str
trace_id: str
def record_security_event(
event: SecurityEvent,
) -> None:
logger.info(
json.dumps(
asdict(event)
)
)
Tokens, passwords, API keys, cookies, and secret values should never be attached to these events.
Common Mistakes
| Mistake | Production Impact | Better Approach |
|---|---|---|
| Checking only authentication | Valid users can access foreign resources. | Enforce resource-level authorization. |
| Trusting resource IDs | Sequential or leaked identifiers expose data. | Scope resources to tenant or ownership. |
| Concatenating SQL strings | Input can alter query semantics. | Use parameterized queries. |
| Depending on validation alone for SQL safety | Unexpected accepted input can still become query syntax. | Separate SQL structure from values. |
| Passing input through a shell | Command injection becomes possible. | Avoid shells and use argument-based process APIs. |
| Blindly fetching user URLs | Internal services may become reachable through SSRF. | Restrict destinations and isolate network access. |
| Rate limiting only by IP | Distributed attackers can bypass limits. | Combine IP, credential, tenant, and endpoint limits. |
| Using one limit for all endpoints | Expensive APIs remain vulnerable to amplification. | Limit according to operation cost. |
| No concurrency limits | Slow requests consume all worker capacity. | Bound concurrent expensive operations. |
| No response-size or pagination limits | Large queries consume database, memory, and network resources. | Enforce server-side maximums. |
| Binding JSON directly to database models | Clients may modify privileged fields. | Use explicit request schemas. |
| Serializing database models directly | Internal fields may leak as schemas evolve. | Use explicit response contracts. |
| Giving leaked credentials broad access | One compromise affects many resources. | Use narrow scopes and independent credentials. |
| Trusting the WAF as the primary defense | Bypasses or unknown patterns reach insecure application code. | Keep security controls inside application boundaries too. |
| Logging malicious payloads blindly | Logs can leak secrets or become excessively expensive. | Record bounded structured security context. |
Production Checklist
- Map untrusted inputs: include headers, paths, queries, JSON, files, URLs, and messages.
- Map dangerous sinks: identify databases, shells, templates, filesystems, and network fetches.
- Authenticate protected endpoints: establish identity before sensitive work.
- Authorize every protected resource: never rely on resource ID secrecy.
- Scope database access by tenant: include trusted ownership in queries.
- Protect nested resources: authorize labels, files, and child objects through their owner.
- Use parameterized SQL: never concatenate untrusted values into statements.
- Validate request schemas: enforce types, lengths, ranges, and accepted formats.
- Avoid shell execution: use structured library or process APIs where possible.
- Restrict URL-fetch features: allow only required protocols and destinations.
- Block private SSRF destinations: restrict loopback, private, link-local, and metadata networks.
- Control redirects: revalidate destinations after redirects where fetching is supported.
- Restrict outbound networks: prevent application workloads from reaching unnecessary internal systems.
- Rate-limit login endpoints: reduce password guessing and credential stuffing.
- Rate-limit by credential: contain compromised API keys and tokens.
- Apply tenant quotas: protect shared platform capacity.
- Apply endpoint-specific limits: protect expensive operations independently.
- Limit concurrency: bound slow or high-cost operations.
- Use downstream bulkheads: protect dependencies from request amplification.
- Set request timeouts: prevent indefinite worker occupancy.
- Limit request bodies: reject oversized payloads early.
- Limit pagination: enforce server-controlled maximum result sizes.
- Use explicit writable models: prevent mass assignment.
- Use explicit response models: prevent excessive data exposure.
- Use narrow credentials: reduce the blast radius of credential theft.
- Make credentials independently revocable: avoid global shared secrets.
- Monitor authorization denials: detect enumeration and privilege probing.
- Monitor rate-limit rejections: detect abusive or unexpectedly high traffic.
- Monitor authentication anomalies: detect credential attacks.
- Test negative security cases: verify that malicious and cross-tenant requests remain rejected.
Conclusion
Protecting APIs against common attacks requires controlling how untrusted input influences the system. Broken authorization is prevented through tenant- and resource-aware access control. Injection is prevented by separating data from executable syntax. SSRF requires destination validation plus network isolation. Resource exhaustion requires quotas, concurrency limits, timeouts, and protection of downstream dependencies.
Many attacks also exploit legitimate credentials or syntactically valid requests. This is why authentication alone is insufficient. Production APIs need bounded privileges, bounded resource consumption, explicit request and response contracts, and telemetry that reveals suspicious patterns without exposing credentials or overwhelming the observability platform.
Key Takeaway: Secure APIs by constraining influence and blast radius. Treat all external input as untrusted, authorize resources rather than only identities, parameterize database access, isolate network-fetch functionality, bound expensive operations, expose only explicitly approved fields, and combine preventive controls with security observability and negative testing.
Comments (0)