Cybersecurity for Developers
Hands-onIntermediate 35 min· Secure coding · 9 of 13

Securing APIs: rate limits, CORS and input checks

Harden a FastAPI service for Brew & Bean with rate limiting, a CORS allowlist and strict input validation, then test each defence with curl. Learn what CORS does and does not protect.

An API is a door that anyone on the internet can knock on, including scripts that never use your website. This tutorial hardens a small Brew & Bean FastAPI service with three of the most useful defences: rate limiting (slow down abuse), CORS (decide which websites may call your API from a browser) and input checks (never trust what the client sends). You will also learn what CORS does not do, a common misunderstanding.

Prerequisites: Python 3.10 or newer and basic FastAPI knowledge (see *Build an API with Python and FastAPI* in Backend Development). Broader API risks are in *API security essentials* in Backend Development; here we focus on hands-on code. Run everything on your own machine.

What you will do

  1. 1Set up a folder and install the packages.
  2. 2Save the hardened main.py and start the server.
  3. 3Read how each defence works.
  4. 4Test with valid, invalid and repeated requests using curl.
  5. 5Do the exercises to extend it.

Set up

Terminal
mkdir bb-secure-api
cd bb-secure-api
python -m venv .venv
# Windows: .venv\Scripts\activate     macOS/Linux: source .venv/bin/activate
pip install fastapi uvicorn slowapi

Creates a virtual environment and installs FastAPI, the Uvicorn server and slowapi, a rate-limiting library for Starlette and FastAPI.

The hardened API

Save this as main.py. Read the comments: each numbered section is explained below the code.

main.py
from typing import Literal

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, ConfigDict, Field
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address

app = FastAPI()

# ---- 1. Rate limiting -------------------------------------------------
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

# ---- 2. CORS: only our own website may call this API from a browser ----
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],  # replace with your real site in production
    allow_methods=["GET", "POST", "PATCH"],
    allow_headers=["Content-Type"],
)

# ---- 3. Input checks: the server decides what is valid ----------------
MENU = {
    "espresso": 120, "cappuccino": 180, "cold-brew": 220, "filter-coffee": 80,
    "masala-chai": 90, "croissant": 150, "banana-bread": 130, "veg-sandwich": 160,
}
ItemId = Literal[
    "espresso", "cappuccino", "cold-brew", "filter-coffee",
    "masala-chai", "croissant", "banana-bread", "veg-sandwich",
]


class OrderItem(BaseModel):
    model_config = ConfigDict(extra="forbid")  # reject unknown fields
    item_id: ItemId
    qty: int = Field(ge=1, le=20)


class OrderIn(BaseModel):
    model_config = ConfigDict(extra="forbid")
    customer_name: str = Field(min_length=1, max_length=60)
    phone: str = Field(pattern=r"^[6-9][0-9]{9}$")  # 10-digit Indian mobile number
    items: list[OrderItem] = Field(min_length=1, max_length=20)


@app.get("/menu")
@limiter.limit("60/minute")
def get_menu(request: Request):
    return [{"id": k, "price": v} for k, v in MENU.items()]


@app.post("/orders", status_code=201)
@limiter.limit("5/minute")
def create_order(request: Request, order: OrderIn):
    # The price comes from OUR menu, never from the client.
    total = sum(MENU[i.item_id] * i.qty for i in order.items)
    return {"status": "received", "total": total}

A menu endpoint and an order endpoint with rate limits, a CORS allowlist and strict validation. The server computes the total from its own price list.

Terminal
uvicorn main:app --reload

Starts the API at http://127.0.0.1:8000. Interactive docs are at /docs.

1. Rate limiting

Rate limiting means "at most N requests per time window from one client". It protects you from:

- Brute-force guessing of passwords or codes. - Scraping your whole menu or customer data. - Cost and denial of service: a loop of requests that exhausts your server, database or paid AI API.

In the code, get_remote_address identifies a client by IP address, and @limiter.limit("5/minute") allows five order requests per minute per IP. The 6th gets HTTP 429 Too Many Requests. Note that slowapi needs a parameter named request: Request on each limited endpoint.

Limits of in-memory limiting

By default the counters live in the memory of one process. If you run several workers or servers, each has its own counters, so the real limit is higher. In production, store counters in a shared place such as Redis (see *Caching with Redis* in Backend Development). Also, behind a proxy or load balancer every request may appear to come from the proxy's IP, so configure your proxy to pass the real client address and make sure you only trust that header from your own proxy.

Choose limits by endpoint risk: a login or OTP endpoint needs a strict limit (for example 5 per minute), a public menu can allow far more. Per-user limits (using the logged-in user's id as the key) are fairer than per-IP limits because many students share one college or mobile-network IP.

2. CORS

Browsers enforce the same-origin policy: JavaScript on https://evil.example cannot read responses from https://api.brewandbean.example unless the API says that is allowed. An origin is scheme + host + port, so http://localhost:5173 and http://localhost:3000 are different origins.

CORS (Cross-Origin Resource Sharing) is how your API tells the browser which origins may read its responses, using headers such as Access-Control-Allow-Origin. In the code, only http://localhost:5173 (the default Vite dev server) is allowed.

CORS settings

SettingSafe choiceRisky choice
allow_originsAn explicit list of your own sites["*"] on an API that uses cookies or private data
allow_methodsOnly the methods you use["*"] out of habit
allow_headersOnly the headers you use["*"] out of habit
allow_credentialsTrue only with an explicit origin list and when you use cookiesTrue combined with a wildcard or an origin copied from the request

The biggest CORS misunderstanding

CORS is not authentication and it does not stop attacks. It only controls what a browser lets other websites read. curl, Postman and attacker scripts ignore CORS completely. You still need authentication, authorisation, validation and rate limits on every endpoint.

A typical beginner problem: the browser shows "blocked by CORS policy" during development. The fix is to add your frontend's exact origin to the allowlist (check the port!), not to set * everywhere. Remember that the error comes from the browser, so the request may still have reached your server.

3. Input checks

Anything from outside your server is untrusted: JSON bodies, query strings, headers, file uploads. The rule is validate on the server, with an allowlist: define what is acceptable and reject everything else, instead of trying to list bad things.

In the code:

- item_id is a Literal of real menu ids, so "banana" is rejected. - qty must be 1 to 20. Without a limit, someone could order 999999999 espressos or send a negative quantity to get a negative total. - phone must match a 10-digit Indian mobile pattern. - Strings have a max_length, and lists have a max_length, to avoid huge payloads. - extra="forbid" rejects unknown fields, so a client cannot sneak in a total or is_admin field. - The total is computed from the server's MENU. Never accept a price from the client.

Validation on the frontend is for user experience only; attackers do not use your frontend. Validation also does not replace safe handling later: still use parameterised SQL queries and output escaping (see *Injection attacks: SQL, command and XSS*).

Test it

Terminal
# 1. A valid order works
curl -i -X POST http://127.0.0.1:8000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer_name":"Asha","phone":"9876543210","items":[{"item_id":"cappuccino","qty":2}]}'

# 2. A bad order is rejected: invalid item, negative qty, extra field
curl -i -X POST http://127.0.0.1:8000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer_name":"Asha","phone":"9876543210","total":1,"items":[{"item_id":"gold","qty":-5}]}'

Use Git Bash, macOS or Linux. On Windows PowerShell, use curl.exe and write the JSON with escaped quotes, or use the /docs page instead.

First request
HTTP/1.1 201 Created
...
{"status":"received","total":360}
Second request
HTTP/1.1 422 Unprocessable Entity
...
{"detail":[{"type":"literal_error","loc":["body","items",0,"item_id"], ...},
{"type":"greater_than_equal","loc":["body","items",0,"qty"], ...},
{"type":"extra_forbidden","loc":["body","total"], ...}]}

Now test the rate limit. Send the valid order six times quickly and watch the last one fail. Bash makes this easy:

Terminal
for i in 1 2 3 4 5 6; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8000/orders \
    -H "Content-Type: application/json" \
    -d '{"customer_name":"Asha","phone":"9876543210","items":[{"item_id":"espresso","qty":1}]}'
done

Prints the HTTP status of each request.

Expected status codes
201
201
201
201
201
429

More habits for every API

API hardening checklist

  1. Require authentication on private endpoints and check authorisation per object (see *Authorisation and broken access control*).
  2. Set a maximum request body size at your proxy or server so nobody can send gigabytes.
  3. Return generic error messages. Never send stack traces, SQL errors or file paths to clients; log them on the server.
  4. Always use HTTPS in production, and set short timeouts on outgoing calls.
  5. Return only the fields the client needs (no password hashes, internal ids or other users' data).
  6. Use pagination limits (for example max 100 items per page) on list endpoints.
  7. Do not put secrets or tokens in URLs; they end up in logs and browser history.
  8. Keep an inventory of your endpoints. Old, forgotten versions such as /api/v1 are a common weak spot.
  9. Log security events (failed logins, 429s, validation spikes) without logging passwords or full tokens.
Prompt · Review my API for weaknesses
Act as an application security reviewer. Below is my [FastAPI / Express] endpoint code. Review it for: missing authentication or authorisation, missing input validation and size limits, unsafe CORS, missing rate limits, information leaks in errors, and trusting client-supplied values (such as prices or user ids). For each issue give: where it is, how it could be abused in plain words, and a small code fix. Do not invent issues; if something is fine, say so.

<code>
[paste your code here]
</code>

What to expect: Check each suggestion yourself and test the fix. Never paste real secrets into an AI tool.

Try it: Add a login limit and a body check

Extend main.py: 1) Add POST /login that accepts a JSON body with username (3 to 30 characters) and password (8 to 100 characters) and always returns {"ok": false} for now. Limit it to 3 requests per minute. 2) Add a GET /orders/{order_id} route where order_id must be an integer of at least 1 (use Path(ge=1) from fastapi). 3) Prove with curl that the 4th login in a minute returns 429 and that /orders/0 returns 422.

Try it: Break CORS safely

Create a tiny HTML page on your machine that calls fetch('http://127.0.0.1:8000/menu'), and serve it with python -m http.server 9000 from its folder. Open http://localhost:9000. Read the console error. Then add that origin to allow_origins, restart and reload.

Key takeaways

  1. Rate limits slow down brute force, scraping and cost abuse; choose strict limits for login and OTP endpoints.
  2. In-memory limits work per process; use a shared store like Redis when you run more than one instance.
  3. CORS tells browsers which sites may read your responses. It is not a security wall against scripts or curl.
  4. Use an explicit origin list, not *, especially with cookies.
  5. Validate every input on the server with an allowlist: types, ranges, lengths, patterns, and reject extra fields.
  6. Never trust client-supplied prices, totals, roles or user ids; compute or look them up on the server.

Check yourself

Finishing the quiz marks the lesson as done.

1. Your API sets allow_origins=["https://shop.example"]. Which statement is true?

2. Why should the server compute total instead of reading it from the request body?

3. A user hits your endpoint more than its limit. Which status code should they get?

4. What does extra="forbid" on a Pydantic model help prevent?

5. You run four copies of your API behind a load balancer, each with in-memory rate limiting at 5 per minute. What is the effective limit per client, roughly?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free