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/1001 and 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 Origin header into Access-Control-Allow-Origin together with Access-Control-Allow-Credentials: true: that lets every website read your users' data with their own cookies.
  • Add Vary: Origin when 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 SameSite on session cookies explicitly (Lax or Strict; 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 Origin header (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:

Layers of defense for an API. A request from a client, which may be an app, a script, a partner or an attacker, first passes a web application firewall or CDN edge that blocks known-bad patterns, volumetric DDoS and bots. Then an API gateway handles TLS termination, authentication, rate limits and size limits. Finally the API service itself checks object ownership, allowed properties and business rules. Logging and monitoring happen at every layer and feed a SIEM. Each layer stops what it can see; only the service knows who owns what Client app, script, partner or attacker WAF / CDN edge known-bad patterns volumetric DDoS bot filtering API gateway TLS termination authentication rate and size limits API service object ownership allowed properties business rules Logging and monitoring at every layer, sent to a SIEM
Defense in depth for an API. The edge and the gateway filter volume and obvious abuse; authorization of individual objects can only happen in the service.
  • 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 403 or 404 answers on sequential IDs: classic BOLA probing.
  • A spike in 401 responses: 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 429 for 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.