Web and API Security
Protecting web applications and APIs: the OWASP API Security Top 10, broken object-level authorization, CORS and CSRF in practice, rate limiting and API gateways, and secure defaults.
Why APIs are attractive targets
API security is the practice of protecting APIs against misuse: making sure that every call is authenticated, authorized for the exact data and action it asks for, limited in the resources it may use, and logged. It builds on general web application security, but APIs deserve their own attention:
- They go straight to the data. A web page shows what the designer chose; the API behind it often returns more, and accepts requests the page would never send.
-
They are made for machines. Predictable URLs such
as
/invoices/1001and structured responses are exactly what a script needs to try thousands of variations. - They are numerous and change fast. Mobile backends, partner APIs, internal services, old versions that were never switched off: every endpoint is attack surface, and nobody defends the ones they have forgotten.
- Traditional defenses see little. A request for someone else's invoice is perfectly valid HTTP with a valid token; no firewall signature can tell it apart from a legitimate one.
The OWASP API Security Top 10
The Open Worldwide Application Security Project (OWASP) publishes, besides the general OWASP Top 10 for web applications, a separate OWASP API Security Top 10: a ranked list of the most critical API risks, as an awareness and prioritization tool. Its 2023 edition:
| ID | Risk | In one line |
|---|---|---|
| API1:2023 | Broken Object Level Authorization | The API returns or changes an object without checking that the caller may access that particular object. |
| API2:2023 | Broken Authentication | Flawed login or token handling lets attackers act as someone else (no protection against credential stuffing, tokens not validated). |
| API3:2023 | Broken Object Property Level Authorization | The caller can read or write fields of an object they should not: excessive data exposure and mass assignment. |
| API4:2023 | Unrestricted Resource Consumption | No limits on requests, payload sizes, page sizes or costly operations, leading to outages or large bills. |
| API5:2023 | Broken Function Level Authorization | Ordinary users can call administrative functions that are merely hidden in the user interface. |
| API6:2023 | Unrestricted Access to Sensitive Business Flows | A legitimate flow, automated at scale, harms the business: buying up all stock, mass sign-ups, abusing referral rewards. |
| API7:2023 | Server Side Request Forgery | The API fetches a URL supplied by the caller without validation, so it can be made to reach internal systems. |
| API8:2023 | Security Misconfiguration | Missing hardening: verbose errors, permissive CORS, missing TLS or security headers, unneeded methods enabled. |
| API9:2023 | Improper Inventory Management | Old versions, test hosts and undocumented endpoints stay exposed because nobody knows they exist. |
| API10:2023 | Unsafe Consumption of APIs | Data from third-party APIs is trusted blindly instead of being validated like any other input. |
Notice how many entries are about authorization (1, 3, 5 and partly 6). Authentication answers "who are you?" and is mostly handled by a framework or identity provider (see API Authentication). Authorization answers "may you do this to this object?", and only your own code can answer it.
Broken object level authorization
Broken object level authorization (BOLA) means an API accepts an object identifier from the caller — in the path, the query string or the body — and acts on that object without checking that the caller is allowed to. The older name is insecure direct object reference (IDOR); it is the API form of broken access control. A customer of Kestrel Energy's portal opens invoice 1001, changes the number to 1002, and sees a stranger's invoice. Nothing about that request is malformed, which is why it is so common and so hard to spot from outside the application.
Naive and fixed handlers side by side
The toy server below (local only, with fake data) offers the same
invoice under two paths. The naive handler checks only that the
token belongs to someone; the fixed one checks that the
invoice belongs to the caller. http.server is used for
learning only; its documentation says it is not recommended for
production.
import json
import threading
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Fake data for a toy server. Tokens map to users (normally: a validated session or JWT)
TOKENS = {"token-of-customer-a": "customer-a", "token-of-customer-b": "customer-b"}
INVOICES = {
"1001": {"owner": "customer-a", "amount": "49.95"},
"1002": {"owner": "customer-b", "amount": "120.00"},
}
class InvoiceAPI(BaseHTTPRequestHandler):
def do_GET(self):
user = TOKENS.get(self.headers.get("Authorization", "").removeprefix("Bearer "))
if user is None:
return self.reply(401, {"error": "not authenticated"})
style, _, invoice_id = self.path.strip("/").partition("/invoices/")
invoice = INVOICES.get(invoice_id)
if style == "naive":
# BUG: checks that someone is logged in, not that the invoice is theirs
if invoice is None:
return self.reply(404, {"error": "not found"})
else:
# FIX: the object must belong to the caller; "not yours" looks like "not found"
if invoice is None or invoice["owner"] != user:
return self.reply(404, {"error": "not found"})
self.reply(200, {"id": invoice_id, **invoice})
def reply(self, status, body):
data = json.dumps(body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, format, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), InvoiceAPI)
threading.Thread(target=server.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{server.server_address[1]}"
def get(path, token):
request = urllib.request.Request(base + path, headers={"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(request) as response:
return response.status, response.read().decode("utf-8")
except urllib.error.HTTPError as error:
return error.code, error.read().decode("utf-8")
me = "token-of-customer-a"
for path in ["/naive/invoices/1001", "/naive/invoices/1002",
"/v1/invoices/1001", "/v1/invoices/1002"]:
status, body = get(path, me)
print(f"{path:22} {status} {body}")
server.shutdown()
/naive/invoices/1001 200 {"id": "1001", "owner": "customer-a", "amount": "49.95"}
/naive/invoices/1002 200 {"id": "1002", "owner": "customer-b", "amount": "120.00"}
/v1/invoices/1001 200 {"id": "1001", "owner": "customer-a", "amount": "49.95"}
/v1/invoices/1002 404 {"error": "not found"}
Customer A gets customer B's invoice from the naive path, and a
404 from the fixed one. Whether to answer
404 or 403 is a design choice:
403 Forbidden is more honest, but tells the caller the
object exists; 404 reveals nothing, which matters when
IDs are guessable. Either is fine; returning the data is not.
Preventing it
- Check ownership on every request that takes an ID, for reading, changing and deleting alike. The user comes from the validated session or token, never from a field the client sends.
- Scope queries to the user rather than fetching first and checking afterward: ask the database for "invoice 1002 belonging to customer A", so a missing check returns nothing instead of everything.
- Centralize the rule in one authorization layer or policy, so a new endpoint cannot forget it, and deny by default.
- Random IDs (UUIDs) help, but are not a fix. They make guessing hard, yet IDs leak through links, logs and shared screenshots.
- Test with two users. An automated test that logs in as A and requests B's objects catches this class of bug before anyone else does.
Its siblings in the list work the same way one level up or down:
function level (API5) — may this user call
DELETE or /admin/… at all? — and
property level (API3) — may this user see or change
this particular field?
Mass assignment
Mass assignment happens when an API copies every field of an incoming request body onto an internal object, including fields the client was never meant to set. Frameworks make this tempting: "update the customer with whatever JSON came in" is one line of code. A customer updating their email address could send:
{
"email": "new-address@example.com",
"role": "admin",
"creditLimit": 100000
}
If the server blindly applies all three fields, the customer has
just promoted themselves. The fix is an
allowlist of writable fields per operation: the
update-profile endpoint accepts email and
phone and nothing else, rejecting (or at least ignoring
and logging) the rest. Many teams use separate input models
(sometimes called data transfer objects) instead of binding requests
directly to database models, and describe allowed fields in the
API's schema with additionalProperties: false
so a validator refuses unknown fields. The same thinking applies to
output: return an explicit set of fields, not "the whole database
row", or internal fields leak (excessive data exposure, the other
half of API3).
Rate limiting and resource limits
Rate limiting caps how many
requests a client may make in a period of time, and refuses the
rest. It protects against brute-force logins, scraping, runaway
scripts and overload, and it keeps one heavy customer from degrading
the service for everyone. The standard refusal is
429 Too Many Requests (RFC 6585, 2012), ideally with a
Retry-After header that says how many seconds to wait
(or an HTTP date):
HTTP/1.1 429 Too Many Requests
Retry-After: 2
Content-Type: application/problem+json
{"type": "https://api.example.com/problems/rate-limited",
"title": "Too many requests",
"status": 429,
"detail": "Limit is 30 requests per minute for this API key."}
Many APIs also tell well-behaved clients how much budget they have
left. The de facto headers are X-RateLimit-Limit,
X-RateLimit-Remaining and similar, with details that
differ per provider. The IETF is standardizing
RateLimit-Policy and RateLimit headers,
but as of 2026 that work is still an Internet-Draft, not an RFC;
older articles show the names RateLimit-Limit,
-Remaining and -Reset from earlier drafts,
which are outdated.
A token bucket
Several algorithms exist: a fixed window ("30 per calendar minute") is simple but allows double bursts at the window edge; a sliding window smooths that; a token bucket allows short bursts while enforcing an average rate. The bucket holds up to capacity tokens and refills at a steady rate; each request takes one token, and an empty bucket means 429. The example uses fixed request times instead of a real clock, so its output is the same on every run.
import math
class TokenBucket:
"""Allow bursts of up to `capacity` requests, refilled at `rate` per second."""
def __init__(self, capacity, rate):
self.capacity = capacity
self.rate = rate
self.tokens = capacity
self.updated = 0.0
def allow(self, now):
# Refill for the time that passed, but never above capacity
elapsed = now - self.updated
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
self.updated = now
if self.tokens >= 1:
self.tokens -= 1
return True, 0
retry_after = math.ceil((1 - self.tokens) / self.rate) # whole seconds
return False, retry_after
bucket = TokenBucket(capacity=3, rate=0.5) # burst of 3, then 1 request per 2 s
# Request times in seconds; a real server would use time.monotonic()
for now in [0.0, 0.1, 0.2, 0.3, 1.0, 2.5, 2.6, 8.0]:
allowed, retry_after = bucket.allow(now)
if allowed:
print(f"t={now:>4}s 200 OK")
else:
print(f"t={now:>4}s 429 Too Many Requests Retry-After: {retry_after}")
t= 0.0s 200 OK t= 0.1s 200 OK t= 0.2s 200 OK t= 0.3s 429 Too Many Requests Retry-After: 2 t= 1.0s 429 Too Many Requests Retry-After: 2 t= 2.5s 200 OK t= 2.6s 429 Too Many Requests Retry-After: 2 t= 8.0s 200 OK
In production, one bucket exists per client key (API key, user or token, and only as a last resort IP address, since many users can share one address behind carrier-grade NAT), and the counts live in a shared store so all server instances see the same budget. Usually the API gateway enforces the limits before requests reach the application.
Rate limits are only part of API4, Unrestricted Resource
Consumption. Also limit request body size, maximum page size (?limit=1000000
should not work), upload sizes, execution time, and the number of
costly side effects: an endpoint that sends an SMS verification code
costs real money every time it is called. Clients, in turn, should
honor Retry-After
and back off exponentially instead of retrying at once.
CORS and CSRF in practice
Two browser mechanisms are constantly confused with API security. Both are explained on Browsers and the Same-Origin Policy; here is what they mean for the people who run an API.
CORS is not access control. Cross-origin resource sharing lets your server tell browsers which other origins' scripts may read its responses. It relaxes the same-origin policy; it never makes an API safer. curl, Postman and an attacker's script ignore it completely. Practical rules:
-
For a public API without cookies,
Access-Control-Allow-Origin: *is fine: anyone may read it anyway — but never on an internal or IP-restricted API, where*lets any website read it through the user's browser. -
For an API that uses cookies or other credentials, list the exact
origins you trust. Never copy the request's
Originheader intoAccess-Control-Allow-Origintogether withAccess-Control-Allow-Credentials: true: that lets every website read your users' data with their own cookies. -
Add
Vary: Originwhen the allowed origin depends on the request, so caches do not serve one origin's answer to another.
CSRF is a cookie problem.
Cross-site request forgery
works because browsers attach cookies automatically, even to
requests started by another site. An API authenticated by a bearer
token in the Authorization header is not exposed in the
same way, because the browser does not add that header by itself. An
API that relies on session cookies — common for a portal's own back
end — needs defenses:
-
Set
SameSiteon session cookies explicitly (LaxorStrict; see Cookies, Sessions and State). Chromium-based browsers default to Lax, but Firefox and Safari do not, so never rely on the default. -
Use anti-CSRF tokens for state-changing requests, or check the
Originheader (and, in modern browsers,Sec-Fetch-Site) and refuse cross-site requests. -
Never change state with
GET: a link or image tag can trigger it from anywhere.
Choosing tokens over cookies moves the risk rather than removing it:
a token that page scripts can read can be stolen through
cross-site scripting
(XSS), while an HttpOnly cookie cannot be read by
scripts at all, though injected script can still send requests that
carry it. Preventing XSS — context-aware output encoding, a Content
Security Policy, no untrusted HTML — protects both.
Security headers
Security headers are HTTP response headers that tell the browser to switch on extra protections for a site: use only HTTPS, load scripts only from approved places, do not guess content types, do not allow framing. They cost almost nothing and limit the damage of many mistakes. A recommended set:
| Header | Typical value | What it does |
|---|---|---|
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
HTTPS only for a year, no downgrade to HTTP (HSTS; see HTTPS and TLS in Practice) |
Content-Security-Policy |
default-src 'self'; frame-ancestors 'none'
|
Where scripts, styles and frames may come from; limits XSS damage |
CSP frame-ancestors |
'none' or 'self' |
Which sites may embed the page in a frame (against
clickjacking); replaces the older
X-Frame-Options
|
X-Content-Type-Options |
nosniff |
Trust the Content-Type; do not guess (so a JSON
or text upload is never run as a script)
|
Referrer-Policy |
strict-origin-when-cross-origin |
Send only the origin, not the full URL with its IDs and query string, to other sites |
For a pure JSON API, which never serves pages, an even stricter
policy works:
Content-Security-Policy: default-src 'none'; frame-ancestors
'none', plus Cache-Control: no-store on responses with
personal data. The obsolete X-XSS-Protection header can
be left out. Content Security Policy itself is explained on
Browsers and the Same-Origin Policy; start with Content-Security-Policy-Report-Only to
find what a strict policy would break. A non-run Nginx example for a
portal and its API:
server {
listen 443 ssl;
server_name portal.example.com;
# certificates and proxy settings omitted
# "always" adds the headers to error responses (4xx/5xx) too
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
server {
listen 443 ssl;
server_name api.example.com;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Cache-Control "no-store" always;
}
Gateways, WAFs and monitoring
No single component secures an API. Each layer stops what it can see, and the layers closest to the data know the most:
- An API gateway is the single front door: it terminates TLS, validates tokens or API keys, enforces rate and size limits, routes to back-end services and logs every call. It is also the natural place for an inventory (API9): if every API must be published through the gateway, forgotten endpoints become rare.
- A web application firewall (WAF) blocks requests matching known attack patterns and helps against bots and floods. It cannot know that invoice 1002 is not yours; BOLA sails straight through.
- Validate all input against the API's schema (types, lengths, allowed fields), and treat responses from third-party APIs as untrusted input too (API10). See input validation.
Logging and monitoring APIs
Many API breaches ran for months because nobody was watching. Log, for every call: the time, the authenticated identity (user, client or API key ID), the method and path, the object IDs involved, the status code, and a request ID that follows the call through every service. Never log secrets: no passwords, full tokens, API keys or complete payment details.
Patterns worth an alert, ideally in a SIEM (see Security Monitoring and the SOC):
-
One client receiving many
403or404answers on sequential IDs: classic BOLA probing. -
A spike in
401responses: credential stuffing or an expired secret in a partner's integration. - One account suddenly reading far more objects than usual, or traffic to an API version that should be retired.
-
Repeated
429for one key: a broken client or abuse.
Further study
The OWASP API Security project publishes each risk with examples and prevention advice, and the OWASP Cheat Sheet Series has free, practical guides (REST security, GraphQL, CSRF prevention, XSS prevention). For people who want to specialize, APIsec University offers paid API security certifications; as of 2026 its site lists an entry-level, multiple-choice certification (CASA) and more advanced ones up to a hands-on, 12-hour practical exam (ASCP). These are security specializations rather than fundamentals, and names and prices change, so check the provider's current information before you plan around them.