Skip to content

API Security Fundamentals

💡
Before you start

You need Python 3 and a text editor — nothing else, and no internet. Every script below is a few lines you run locally. Check with python3 --version in a terminal (on Windows, py --version); any 3.x is fine. If it prints nothing useful, do Introduction to Python first — about fifteen minutes.

🔴 Everything here runs on your own machine, against files you create. The validation ideas apply to any language and any web framework; Python is just the shortest way to see them work.

Why API Security Matters

APIs (Application Programming Interfaces) are the backbone of modern applications. They connect mobile apps to backends, link microservices together, and expose data to third-party integrations. Because APIs handle sensitive data and business logic, they are prime targets for attackers.

Unlike traditional web applications where a browser enforces some security controls, APIs are often accessed programmatically. This means attackers can easily automate requests, manipulate parameters, and bypass client-side checks.

💡
OWASP API Security Top 10

OWASP maintains a dedicated API Security Top 10 list, separate from the web application Top 10. The most critical API vulnerabilities include broken object-level authorization, broken authentication, and excessive data exposure.

Authentication Methods

Every API must verify the identity of its callers. There are several authentication mechanisms, each with different security profiles and use cases.

API Keys

API keys are simple tokens passed in headers or query parameters. They identify the calling application but not the user. API keys alone are not sufficient for user-level authentication.

# API key in header (preferred)
curl -H "X-API-Key: your-api-key-here" https://api.example.com/data

# API key in query parameter (less secure - logged in URLs)
curl https://api.example.com/data?api_key=your-api-key-here

OAuth 2.0 and JWT

OAuth 2.0 is the industry standard for delegated authorization. It issues access tokens (often as JWTs) that carry claims about the user and their permissions. JWTs are cryptographically signed, allowing the API to verify them without contacting the auth server on every request.

# Bearer token authentication
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
     https://api.example.com/user/profile
⚠️
Never trust client-supplied JWTs blindly

Always verify the signature, check the expiration (exp claim), validate the issuer (iss), and confirm the audience (aud). Accepting unsigned tokens or tokens with alg: none is a critical vulnerability.

Rate Limiting and Throttling

Without rate limiting, attackers can abuse your API through brute force attacks, data scraping, or denial of service. Rate limiting controls how many requests a client can make within a time window.

Common Strategies

  • Fixed Window - Allow N requests per time window (e.g., 100 requests per minute)
  • Sliding Window - Smoother rate limiting that avoids burst traffic at window boundaries
  • Token Bucket - Allows short bursts while maintaining an average rate
  • Per-Endpoint Limits - Different limits for different endpoints based on cost and sensitivity

Response Headers

Communicate rate limit status to clients through standard headers:

HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1709424000

# When limit is exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 30

Input Validation for APIs

APIs must validate every piece of incoming data. Unlike web forms, API consumers can send any JSON structure, any content type, and any parameter values. Never trust client input.

Schema Validation

Use a schema definition (like JSON Schema or OpenAPI) to validate the structure and types of request bodies before processing them.

// Example JSON Schema for a user creation endpoint
{
  "type": "object",
  "required": ["email", "name"],
  "properties": {
    "email": {
      "type": "string",
      "format": "email",
      "maxLength": 254
    },
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100
    },
    "role": {
      "type": "string",
      "enum": ["user", "editor"]
    }
  },
  "additionalProperties": false
}
⚠️
Watch for mass assignment

If your API automatically maps request fields to database columns, an attacker could add fields like "role": "admin" or "is_verified": true. Always use an allowlist of accepted fields and reject unknown properties.

CORS Configuration

Cross-Origin Resource Sharing (CORS) controls which domains can call your API from a browser. Misconfigured CORS is one of the most common API security issues.

Secure CORS Setup

# Good: Specific allowed origins
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

# Bad: Wildcard allows any website to call your API
Access-Control-Allow-Origin: *
  • Never use wildcard origins with credentialed requests
  • Never reflect the Origin header directly into Access-Control-Allow-Origin without validation
  • Limit allowed methods to only what each endpoint needs
  • Set a reasonable max-age for preflight caching

Error Handling and Data Exposure

API error responses can leak sensitive information if not handled carefully. Stack traces, database error messages, and internal paths should never reach the client.

Good vs Bad Error Responses

// BAD: Leaks internal details
{
  "error": "SQL Error: SELECT * FROM users WHERE id = '1 OR 1=1'",
  "stack": "at Database.query (db.js:45)\n    at UserController..."
}

// GOOD: Generic message with reference ID
{
  "error": "An internal error occurred",
  "code": "INTERNAL_ERROR",
  "reference": "err-2026-03-abc123"
}

Log detailed errors server-side with the reference ID so you can investigate without exposing details to attackers.

Monitoring and Logging

You cannot secure what you cannot see. API monitoring detects attacks in progress and helps with forensic analysis after incidents.

What to Log

  • Authentication events - Failed logins, token refresh, privilege changes
  • Authorization failures - Attempts to access unauthorized resources
  • Rate limit violations - IPs or users hitting rate limits repeatedly
  • Input validation failures - Malformed requests may indicate attack attempts
  • Unusual patterns - Sudden spikes in traffic, unusual endpoints being accessed
💡
Never log sensitive data

Exclude passwords, API keys, tokens, credit card numbers, and personal data from logs. If you must log request bodies, sanitize sensitive fields first.

Now Do It Yourself: Five Steps

Most API security comes down to two questions asked on every request: who are you and how often. You will take apart a JSON Web Token to see it is signed, not secret, forge one and watch the signature catch you, block a flood with a rate limiter, and then meet the single most famous JWT vulnerability — a forged token your own verifier waves through if you wrote it carelessly. Every output below was captured by running the code on Python 3.12, all with the standard library.

1
Open a token: it is signed, not secret

Go: open a terminal, make a folder with mkdir api-lab and cd api-lab, and open your text editor.

Do: save these thirteen lines as jwt_decode.py, then run python3 jwt_decode.py.

import base64, json

def b64url_decode(part):
    part += "=" * (-len(part) % 4)
    return base64.urlsafe_b64decode(part)

token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
         ".eyJ1c2VyIjoiYWxpY2UiLCJyb2xlIjoidXNlciJ9"
         ".c2lnbmF0dXJlX2dvZXNfaGVyZQ")

header_b64, payload_b64, sig_b64 = token.split(".")
print("header :", json.loads(b64url_decode(header_b64)))
print("payload:", json.loads(b64url_decode(payload_b64)))

You should see: header : {'alg': 'HS256', 'typ': 'JWT'} and payload: {'user': 'alice', 'role': 'user'}. Read that twice: you just recovered the contents of a token with no key at all. A JWT is three base64 pieces joined by dots, and the first two are only encoded, not encrypted — anyone holding the token can read them. Never put a secret in a JWT payload. What stops tampering is not secrecy; it is the third piece, the signature, which is next.

If not: binascii.Error: Invalid base64 almost always means a piece got split wrong — the token must be one continuous string with exactly two dots. The padding line (part += "=" * ...) matters: JWTs strip base64 padding, and Python’s decoder needs it added back.

2
Sign your own, and verify it

Go: same folder, new file.

Do: save these twenty lines as jwt_make.py, then run it.

import base64, json, hmac, hashlib

SECRET = b"server-only-signing-key"

def b64url(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def make_token(payload):
    header = {"alg": "HS256", "typ": "JWT"}
    h = b64url(json.dumps(header, separators=(",", ":")).encode())
    p = b64url(json.dumps(payload, separators=(",", ":")).encode())
    sig = b64url(hmac.new(SECRET, f"{h}.{p}".encode(), hashlib.sha256).digest())
    return f"{h}.{p}.{sig}"

def verify(token):
    h, p, sig = token.split(".")
    expected = b64url(hmac.new(SECRET, f"{h}.{p}".encode(), hashlib.sha256).digest())
    return hmac.compare_digest(sig, expected)

token = make_token({"user": "alice", "role": "user"})
print("issued token:", token)
print("verifies:", verify(token))

You should see: a long header.payload.signature string, then verifies: True. The signature is an HMAC of the first two pieces using a key only your server holds. To verify, the server recomputes that HMAC and compares — with hmac.compare_digest, not ==, for the timing reason from the login pages. Because the attacker does not have SECRET, they cannot produce a matching signature for any payload they invent, which is exactly what step 3 tests.

If not: if verifies is False, the signing and checking used different bytes — usually a stray space, since separators=(",", ":") is what removes the spaces Python’s json.dumps adds by default. Both sides must serialise identically, which is why real libraries sign the exact received bytes rather than re-serialising.

3
Forge a token — and watch the signature catch it

Go: same folder, new file.

Do: save these twenty-two lines as jwt_tamper.py, then run it.

import base64, json, hmac, hashlib

SECRET = b"server-only-signing-key"
def b64url(data): return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def b64url_dec(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def make_token(payload):
    h = b64url(json.dumps({"alg":"HS256","typ":"JWT"},separators=(",",":")).encode())
    p = b64url(json.dumps(payload,separators=(",",":")).encode())
    sig = b64url(hmac.new(SECRET, f"{h}.{p}".encode(), hashlib.sha256).digest())
    return f"{h}.{p}.{sig}"

def verify(token):
    h, p, sig = token.split(".")
    expected = b64url(hmac.new(SECRET, f"{h}.{p}".encode(), hashlib.sha256).digest())
    return hmac.compare_digest(sig, expected)

token = make_token({"user": "alice", "role": "user"})
h, p, sig = token.split(".")
forged_payload = b64url(json.dumps({"user":"alice","role":"admin"},separators=(",",":")).encode())
forged = f"{h}.{forged_payload}.{sig}"
print("original verifies:", verify(token))
print("forged role in payload:", json.loads(b64url_dec(forged_payload)))
print("forged verifies:", verify(forged))

You should see: original verifies: True, then the forged payload showing 'role': 'admin', then forged verifies: False. The attacker successfully rewrote the token to make themselves an admin — the payload is not secret, remember — but kept the old signature, which no longer matches the new contents. This is the whole security model: read all you like, change one byte and the signature check fails, because reproducing it needs a key you do not have.

If not: if the forged token verifies as True, you re-signed it with make_token instead of splicing the old signature onto the new payload — which would just be issuing a legitimate token, not forging one. The forgery must reuse sig from the original.

4
Answer “how often” with a rate limiter

Go: same folder, new file.

Do: save these eighteen lines as ratelimit.py, then run it.

class RateLimiter:
    def __init__(self, max_calls, per_seconds):
        self.max_calls = max_calls
        self.per = per_seconds
        self.hits = {}

    def allow(self, client_ip, now):
        window = self.hits.setdefault(client_ip, [])
        window[:] = [t for t in window if t > now - self.per]
        if len(window) >= self.max_calls:
            return False
        window.append(now)
        return True

limiter = RateLimiter(max_calls=3, per_seconds=60)
now = 1000.0
for i in range(5):
    ok = limiter.allow("203.0.113.7", now + i)
    print(f"request {i+1}: {'200 OK' if ok else '429 Too Many Requests'}")

You should see: requests 1–3 return 200 OK and requests 4–5 return 429 Too Many Requests. This is a sliding window: each client’s recent hits are remembered, anything older than the window is forgotten, and once the count reaches the limit the rest are refused. Rate limiting is not only about abuse — it is what stops one client’s runaway loop from starving everyone else, and what makes credential-stuffing and brute-force attacks too slow to be worth running. Real deployments key it on the API token or account, not only the IP, since IPs are cheap to change.

If not: if every request returns 200 OK, the window pruning is discarding all the timestamps — check the comparison is t > now - self.per, keeping recent hits, not t < now - self.per, which keeps only the ancient ones and so never fills up.

5
The famous JWT hole: trusting the token’s own algorithm

Go: same folder, new file.

Do: save these twenty-four lines as alg_none.py, then run it. It builds a token that claims to need no signature.

import base64, json, hmac, hashlib

SECRET = b"server-only-signing-key"
def b64url(d): return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
def b64url_dec(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def verify_naive(token):
    h, p, sig = token.split(".")
    alg = json.loads(b64url_dec(h))["alg"]
    if alg == "none":
        return True
    expected = b64url(hmac.new(SECRET, f"{h}.{p}".encode(), hashlib.sha256).digest())
    return hmac.compare_digest(sig, expected)

def verify_safe(token):
    h, p, sig = token.split(".")
    if json.loads(b64url_dec(h))["alg"] != "HS256":
        return False
    expected = b64url(hmac.new(SECRET, f"{h}.{p}".encode(), hashlib.sha256).digest())
    return hmac.compare_digest(sig, expected)

evil_h = b64url(json.dumps({"alg":"none","typ":"JWT"},separators=(",",":")).encode())
evil_p = b64url(json.dumps({"user":"attacker","role":"admin"},separators=(",",":")).encode())
forged = f"{evil_h}.{evil_p}."
print("naive verifier accepts alg:none forgery:", verify_naive(forged))
print("safe verifier rejects it:", verify_safe(forged))

You should see: naive verifier accepts alg:none forgery: True, then safe verifier rejects it: False. The forged token has no signature at all — and the naive verifier accepts it, because it read the algorithm out of the attacker-controlled header and obediently did no checking when told "alg": "none". This is a real vulnerability that has appeared in shipped libraries. The fix is one line: the server decides which algorithm it accepts (verify_safe demands HS256) and never lets the token choose. The rule generalises far past JWTs: never let untrusted input select your security algorithm.

If not: if the safe verifier also returns True, its algorithm check is comparing against the wrong value, or is missing — it must reject anything that is not exactly the algorithm you chose, before it does any signature work.

🎉
Check yourself before moving on

Without scrolling up: in step 1 you read a token’s payload with no key whatsoever. So what actually stops an attacker changing "role": "user" to "role": "admin" and using it? Answer: nothing stops them changing it — the payload is not secret — but the signature no longer matches, and without the server’s key they cannot compute one that does, so the server rejects the token.

Now do it without the page: add an exp (expiry) field to the payload in make_token, and extend verify to reject a token whose exp is in the past — after the signature check passes, never before. Think about why the order matters. Hint: reading any field out of a token you have not yet verified is trusting unsigned data, the exact mistake step 5 punished.

Summary

In this tutorial, you learned:

  • Why APIs are high-value targets and need dedicated security measures
  • How to choose and implement authentication (API keys, OAuth, JWT)
  • Rate limiting strategies to prevent abuse and denial of service
  • Input validation with schema definitions to reject malformed requests
  • Proper CORS configuration to control cross-origin access
  • Secure error handling that avoids leaking internal details
  • What to monitor and log for attack detection
🎉
Your APIs are fortified!

By applying these fundamentals consistently, you build APIs that are resilient against the most common attack vectors. Remember: defense in depth means applying multiple layers of security, not relying on any single control.