Secure Your AI Inference API

Add authentication, rate limiting, and security headers to a FastAPI ML API.

Introduction

30 Second Summary

Imagine leaving your front door wide open with a sign that says "free stuff inside." That is what a public API without authentication or rate limiting looks like to the internet.

In this project, you will secure your existing AI inference API by adding API key authentication, a Redis-backed sliding window rate limiter, and production security headers that protect against common web attacks.

What You'll Build

You will fire requests at your API and watch it reject unauthorized callers instantly, count down remaining requests in real time via response headers, and block oversized payloads before they ever reach your ML model.

By the end of this project, you'll have:

  • A locked-down inference API that rejects unauthenticated requests with a clear 403 error and only serves clients presenting a valid X-API-Key header.
  • A sliding window rate limiter powered by Redis sorted sets that returns a 429 response with a Retry-After countdown when clients exceed their quota.
  • Production security headers and CORS policy on every response, with input validation that blocks oversized payloads before they consume inference compute.
  • Secret Mission: Add tiered rate limits so "free" keys get 5 requests per minute and "premium" keys get 30 requests per minute.

Are there any prerequisites?

This project builds on top of your working FastAPI inference API from Project 1. You should already have a running API with /health and /similarity endpoints, plus Docker Desktop installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Dependencies and Redis

Your inference API from Project 1 is functional but completely unprotected. Anyone who discovers the URL can call it unlimited times with no authentication and no throttling.

Before building any security layers, you need the right foundations. Redis provides high-speed storage for tracking request counts per client, and pydantic-settings manages your configuration securely through environment variables.

In this step, get ready to:
  • Install the Redis and pydantic-settings Python packages.
  • Create a Docker Compose file to run a Redis container.
  • Set up environment-based configuration with Pydantic.
Install Python dependencies

Your existing project needs two new packages to support authentication and rate limiting.

  • Install the security-related packages by running this command:
pip install redis==8.0.1 pydantic-settings==2.14.2

You should see pip download and install both packages along with their dependencies (including python-dotenv).

Seeing a permission error?

If pip reports a permissions issue, make sure you activated your virtual environment first. Run source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows) before retrying.

help me fix a pip permission error when installing redis and pydantic-settings

Now you need to update your requirements.txt to record all project dependencies, including the new ones.

  • Open requirements.txt in VS Code and replace its contents with the following:
fastapi==0.139.2
uvicorn==0.34.0
sentence-transformers==3.4.1
scikit-learn==1.6.1
redis==8.0.1
pydantic-settings==2.14.2
python-dotenv==1.1.0

What does this file do?

The requirements.txt file pins every package to an exact version so anyone who clones your project gets the same dependencies. The three new additions are redis (the Python client for Redis), pydantic-settings (type-safe config from environment variables), and python-dotenv (reads .env files automatically).

  • Save requirements.txt.

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before moving on.

ⓧ I'd like to double check the full code

fastapi==0.139.2
uvicorn==0.34.0
sentence-transformers==3.4.1
scikit-learn==1.6.1
redis==8.0.1
pydantic-settings==2.14.2
python-dotenv==1.1.0
Create docker-compose.yml for Redis

Redis is an in-memory data store that your rate limiter will use to track how many requests each client has made. You will run it as a Docker container so it stays isolated from your system.

  • Create a new file called docker-compose.yml at the root of your project folder (the same level as requirements.txt).
  • Paste the following into it:
services:
  redis:
    image: redis:7.4
    ports:
      - "6379:6379"
    restart: unless-stopped

What does this file do?

  • image: redis:7.4 pulls the official Redis 7.4 image from Docker Hub.
  • ports: "6379:6379" maps the container's port 6379 to your machine's port 6379, so your Python code can reach Redis at localhost:6379.
  • restart: unless-stopped means the container automatically restarts if it crashes or your machine reboots, unless you explicitly stop it.
  • Save docker-compose.yml.
  • Start the Redis container by running this command:
docker compose up -d

Docker will pull the Redis image (this may take a moment the first time) and start the container in the background. You should see output indicating the redis container has been created and started.

Seeing a connection or daemon error?

Make sure Docker Desktop is running before you execute the command. Open Docker Desktop from your system tray or applications, wait for it to show "Engine running", and retry.

help me fix a docker compose error when starting the Redis container

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before moving on.

ⓧ I'd like to double check the full code

services:
  redis:
    image: redis:7.4
    ports:
      - "6379:6379"
    restart: unless-stopped
Create environment configuration

Your API needs configuration values like valid API keys and rate limit thresholds. Storing these in environment variables keeps secrets out of your source code.

  • Create a new file called .env at the root of your project folder and paste the following:
API_KEYS=sk-dev-key-001,sk-dev-key-002
REDIS_URL=redis://localhost:6379
RATE_LIMIT_REQUESTS=10
RATE_LIMIT_WINDOW_SECONDS=60
CORS_ORIGINS=*

What do these values mean?

  • API_KEYS is a comma-separated list of keys that are allowed to access your API.
  • REDIS_URL tells your app where to find the Redis container you just started.
  • RATE_LIMIT_REQUESTS and RATE_LIMIT_WINDOW_SECONDS together mean "allow 10 requests per 60 seconds per client".
  • CORS_ORIGINS controls which web domains can call your API. The * wildcard allows all origins during development.
  • Save .env.
  • Create an empty file at app/__init__.py (this tells Python the app folder is a package). You can create it by running:
touch app/__init__.py
  • Create a new file called app/config.py and paste the following:
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    api_keys: str = ""
    redis_url: str = "redis://localhost:6379"
    rate_limit_requests: int = 10
    rate_limit_window_seconds: int = 60
    cors_origins: str = "*"

    @property
    def valid_api_keys(self) -> set[str]:
        return {k.strip() for k in self.api_keys.split(",") if k.strip()}

    model_config = {"env_file": ".env"}


settings = Settings()

What does this code do?

  • Settings extends BaseSettings from pydantic-settings. It automatically reads environment variables matching each field name (case-insensitive).
  • model_config = {"env_file": ".env"} tells Pydantic to load variables from your .env file when the app starts.
  • The valid_api_keys property splits the comma-separated string into a Python set, making key lookups fast and simple.
  • The settings instance at the bottom is created once and imported by other modules throughout your app.
  • Save app/config.py.

✔️ Awesome, I've got everything!

Great. Double check you saved both .env and app/config.py before moving on.

ⓧ I'd like to double check the full code

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    api_keys: str = ""
    redis_url: str = "redis://localhost:6379"
    rate_limit_requests: int = 10
    rate_limit_window_seconds: int = 60
    cors_origins: str = "*"

    @property
    def valid_api_keys(self) -> set[str]:
        return {k.strip() for k in self.api_keys.split(",") if k.strip()}

    model_config = {"env_file": ".env"}


settings = Settings()

Before you run the verification command, what do you expect to see if Redis is running and your Python client can connect to it?

  • Verify everything works together by running this command:
python -c "import redis; r = redis.Redis(); print(r.ping())"

You should see True printed in your terminal. This confirms the Redis container is running and your Python client can reach it.

Seeing a ConnectionError?

If you see Connection refused, the Redis container may not be running. Run docker compose ps to check its status. If the container shows as exited, run docker compose up -d again and wait a few seconds before retrying.

help me fix a Redis connection error in my Python project

Your security foundation is in place. Next up, you will use these tools to build API key authentication that blocks every unauthorized request before it reaches your ML model.

Add API Key Authentication

In the previous step, you installed your security dependencies and got Redis running. Your infrastructure is ready, but the API itself is still completely unprotected.

Right now, anyone who discovers your API's URL can call /similarity as many times as they want with zero accountability. This step gates every request behind a valid API key so only authorized clients can reach your ML model.

In this step, get ready to:
  • Create an authentication module that validates API keys from request headers.
  • Wire the authentication check into your /similarity endpoint.
  • Test that unauthenticated requests are rejected with a 403 response.
Create the authentication module

FastAPI provides a built-in security utility called APIKeyHeader that extracts a named header from incoming requests. You will use it to build a dependency that checks every request for a valid key.

  • Create a new file called app/auth.py and add the following authentication code:
from fastapi import Security, HTTPException, status
from fastapi.security import APIKeyHeader

from app.config import settings

# Define which header carries the API key
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)


async def verify_api_key(
    api_key: str | None = Security(api_key_header),
) -> str:
    # Reject requests with no API key header
    if api_key is None:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Missing API key. Include an X-API-Key header.",
        )
    # Reject requests with an unrecognized key
    if api_key not in settings.valid_api_keys:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Invalid API key.",
        )
    return api_key

What does this code do?

  • APIKeyHeader(name="X-API-Key", auto_error=False) tells FastAPI to look for a header called X-API-Key on each request. Setting auto_error=False returns None instead of raising an automatic error, giving you control over the error message.
  • verify_api_key is an async dependency function. It checks two failure cases: no key provided, and an invalid key. Both return a 403 Forbidden response.
  • settings.valid_api_keys is the set of keys you defined in .env and loaded via app/config.py in the previous step.
  • Save app/auth.py.
  • Confirm the file exists by running:
ls app/

You should see auth.py listed alongside __init__.py, config.py, and main.py.

Don't see auth.py?

Make sure you created the file inside the app/ folder, not in the project root. The file path should be app/auth.py.

Need help?

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

from fastapi import Security, HTTPException, status
from fastapi.security import APIKeyHeader

from app.config import settings

api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)


async def verify_api_key(
    api_key: str | None = Security(api_key_header),
) -> str:
    if api_key is None:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Missing API key. Include an X-API-Key header.",
        )
    if api_key not in settings.valid_api_keys:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Invalid API key.",
        )
    return api_key
Wire authentication into the similarity endpoint

The authentication module exists, but your API does not use it yet. You need to tell FastAPI to run verify_api_key before processing any similarity request.

  • Open app/main.py.
  • Find the import line that reads:
from fastapi import FastAPI
  • Replace it with the following to add Depends:
from fastapi import Depends, FastAPI
  • Below the other import lines (after the sklearn import), add this new import:
from app.auth import verify_api_key
  • Now scroll down to the compute_similarity function. Find the current function signature:
@app.post("/similarity", response_model=SimilarityResponse)
async def compute_similarity(
    payload: SimilarityRequest,
):
  • Replace it with this version that adds the api_key dependency:
@app.post("/similarity", response_model=SimilarityResponse)
async def compute_similarity(
    payload: SimilarityRequest,
    api_key: str = Depends(verify_api_key),
):

What does Depends do here?

Depends(verify_api_key) tells FastAPI to run the verify_api_key function before executing compute_similarity. If the key check fails, FastAPI returns the 403 error immediately and your endpoint code never runs.

Notice that /health has no dependency added. Monitoring tools and load balancers need to reach the health check without credentials.

  • Save app/main.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before testing.

ⓧ I'd like to double check the full code

Your app/main.py should now have these key sections in order:

  • Imports including from fastapi import Depends, FastAPI and from app.auth import verify_api_key.
  • The lifespan function loading the model (unchanged).
  • The app instance and request/response models (unchanged).
  • The /health endpoint (unchanged, no auth).
  • The /similarity endpoint with api_key: str = Depends(verify_api_key) as a parameter.
Test authenticated vs unauthenticated requests

Both files are in place. Time to start the server and prove that your authentication layer actually blocks unauthorized callers.

  • Start the FastAPI development server by running:
uvicorn app.main:app --reload

Wait for the model to load. You should see a line indicating the server is running on http://127.0.0.1:8000.

Server won't start?

  • If you see an ImportError, double check that the import line reads from app.auth import verify_api_key exactly.
  • If you see a SyntaxError, check the function signature has a comma after payload: SimilarityRequest, and the closing parenthesis is followed by a colon.

Help me fix my server startup error.

Now open a second terminal window to send test requests. Before you run this first command, predict: what do you think the API will return when you send a request without an API key?

  • Send a request with no API key by running:
curl http://localhost:8000/similarity -X POST -H "Content-Type: application/json" -d '{"text1":"hello","text2":"world"}'

You should see a 403 Forbidden response with the message Missing API key. Include an X-API-Key header. Your API just rejected its first unauthorized caller.

  • Now send a request with a valid API key by running:
curl http://localhost:8000/similarity -X POST -H "Content-Type: application/json" -H "X-API-Key: sk-dev-key-001" -d '{"text1":"hello","text2":"world"}'

This time you should see a 200 response with a similarity_score value. The same endpoint, but now only accessible to callers who prove their identity.

  • Verify that your health endpoint still works without a key:
curl http://localhost:8000/health

You should see {"status":"healthy","model_loaded":true} with no 403 error. The health check remains open for monitoring tools.

Still getting a 200 without a key?

  • Confirm your endpoint signature includes api_key: str = Depends(verify_api_key) as a parameter on compute_similarity.
  • If you are using --reload, check the terminal for reload errors. A failed reload means the old code is still running.
  • Make sure you imported Depends from fastapi (not just FastAPI).

Help me debug why my API isn't rejecting unauthenticated requests.

Your API now rejects every unauthorized caller. But authentication alone is not enough. A valid key holder can still hammer your endpoint with thousands of requests per second. Next up, you will add a Redis-backed rate limiter to cap usage per client.

Add Redis-Backed Rate Limiting

Authentication controls who can access your API. But it does nothing to limit how much a valid client can use it.

A single authenticated key holder can still fire thousands of requests per second, overwhelming your ML model and starving other clients. Rate limiting caps how many requests each client can make in a time window. You'll build a sliding window rate limiter backed by Redis that tracks each client's usage with sub-millisecond latency.

In this step, get ready to:
  • Build a sliding window rate limiter using Redis sorted sets and pipelining.
  • Wire the rate limiter into your FastAPI middleware stack.
  • Verify that exceeding 10 requests in 60 seconds triggers a 429 response with a Retry-After header.
Build the sliding window rate limiter

A fixed-window rate limiter resets its count at predictable intervals, which lets clients burst at the boundary between two windows. A sliding window avoids this by tracking every request's timestamp and counting only those within the last N seconds.

Why Redis sorted sets?

Redis sorted sets store members with a score (timestamp). The commands ZREMRANGEBYSCORE (prune expired entries), ZADD (record current request), and ZCARD (count active entries) give you a precise sliding window in three operations.

Running all three in a single Redis pipeline means one network round-trip and atomic execution. The sorted set auto-evicts stale entries, so memory stays bounded.

  • Create a new file app/rate_limiter.py and add the class setup and the start of the dispatch method:
import time
import uuid

import redis.asyncio as aioredis
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse

from app.config import settings


class RateLimiterMiddleware(BaseHTTPMiddleware):
    def __init__(self, app):
        super().__init__(app)
        # Connect to Redis for distributed request tracking
        self.redis = aioredis.from_url(
            settings.redis_url, decode_responses=True
        )
        self.limit = settings.rate_limit_requests
        self.window = settings.rate_limit_window_seconds

    async def dispatch(self, request: Request, call_next):
        # Skip rate limiting for health checks and CORS preflight
        if request.url.path == "/health" or request.method == "OPTIONS":
            return await call_next(request)

        api_key = request.headers.get("X-API-Key")
        client_id = api_key or request.client.host
        key = f"rate_limit:{client_id}"

What does this code do?

  • The class extends BaseHTTPMiddleware so FastAPI calls dispatch on every incoming request.
  • self.redis creates an async Redis connection from the URL in your .env file.
  • Health checks and OPTIONS preflight requests skip rate limiting so monitoring tools and CORS handshakes always work.
  • client_id uses the API key when present, falling back to the client's IP address.
  • Still inside the dispatch method, add the sliding window algorithm and response handling directly below the key = f"rate_limit:{client_id}" line:
        now = time.time()
        window_start = now - self.window
        request_id = f"{now}-{uuid.uuid4()}"
        # Atomic pipeline: prune old entries, add new one, count total
        pipe = self.redis.pipeline()
        pipe.zremrangebyscore(key, 0, window_start)
        pipe.zadd(key, {request_id: now})
        pipe.zcard(key)
        pipe.expire(key, self.window)
        results = await pipe.execute()
        request_count = results[2]
        remaining = max(0, self.limit - request_count)

        if request_count > self.limit:
            return JSONResponse(
                status_code=429,
                content={
                    "detail": "Rate limit exceeded.",
                    "retry_after": self.window,
                },
                headers={
                    "X-RateLimit-Limit": str(self.limit),
                    "X-RateLimit-Remaining": "0",
                    "Retry-After": str(self.window),
                },
            )

        response = await call_next(request)
        response.headers["X-RateLimit-Limit"] = str(self.limit)
        response.headers["X-RateLimit-Remaining"] = str(remaining)
        return response

What does this code do?

  • zremrangebyscore removes all entries older than the window (e.g. older than 60 seconds ago).
  • zadd records the current request with its timestamp as the score.
  • zcard counts remaining entries in the sorted set, giving you the number of requests in the current window.
  • If the count exceeds the limit, the middleware short-circuits with a 429 response and a Retry-After header telling the client when to retry.
  • Successful responses get X-RateLimit-Limit and X-RateLimit-Remaining headers so clients can self-throttle.
  • Save app/rate_limiter.py.
  • Confirm the module loads correctly by running this command:
python -c "from app.rate_limiter import RateLimiterMiddleware; print('Import OK')"

You should see Import OK printed with no errors.

Seeing an ImportError?

  • Check that your file is saved at app/rate_limiter.py (not in the project root).
  • Make sure app/__init__.py exists. Without it, Python does not recognize app as a package.
  • Verify redis is installed by running pip list | grep redis. You should see 8.0.1.

Still stuck? Why is my rate_limiter import failing?

✔️ Awesome, I've got everything!

Great. Double check you have saved app/rate_limiter.py and the import printed Import OK.

ⓧ I'd like to double check the full code

Your app/rate_limiter.py should contain two main sections in order:

  • The first section (from the first code block above): imports, the RateLimiterMiddleware class with __init__, and the start of dispatch through key = f"rate_limit:{client_id}".
  • The second section (from the second code block above): the sliding window pipeline, the 429 response logic, and the rate limit headers on successful responses.

Scroll up and confirm both sections are present and correctly indented inside the dispatch method.

Wire the rate limiter into your application

The middleware class exists but FastAPI does not know about it yet. You need to register it on the app so every request passes through the rate limiter.

  • In app/main.py, find this import line near the top:
from app.auth import verify_api_key
  • Add a new import directly below it so both lines look like this:
from app.auth import verify_api_key
from app.rate_limiter import RateLimiterMiddleware
  • Next, find this line where the app is created:
app = FastAPI(title="AI Inference API", lifespan=lifespan)
  • Add the middleware registration directly below it so the two lines look like this:
app = FastAPI(title="AI Inference API", lifespan=lifespan)

app.add_middleware(RateLimiterMiddleware)
  • Save app/main.py.
  • Your uvicorn server (running with --reload) will restart automatically. Test that rate limit headers appear by running:
curl -i -X POST http://localhost:8000/similarity -H "X-API-Key: sk-dev-key-001" -H "Content-Type: application/json" -d '{"text1":"hello","text2":"world"}'

Look at the response headers. You should see X-RateLimit-Limit: 10 and X-RateLimit-Remaining: 9 alongside the 200 status and your similarity score.

Not seeing rate limit headers?

  • Make sure your Redis container is running. Check with docker compose ps and confirm the redis service shows as running.
  • Verify the REDIS_URL in your .env file is redis://localhost:6379.
  • Check your terminal for uvicorn errors. If the server did not restart cleanly, stop it with Ctrl+C and re-run uvicorn app.main:app --reload.

Still stuck? Why aren't my rate limit headers showing up in curl responses?

Verify rate limit enforcement with rapid requests

You have confirmed that each response carries rate limit headers. Now you need to prove that the limiter actually blocks requests once a client exceeds 10 in 60 seconds.

Before you run the next command, predict: if you send 12 requests rapidly with the same API key, which requests do you expect to succeed and which should be rejected?

  • Fire 12 requests in a loop by running this command:
for i in $(seq 1 12); do curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8000/similarity -H "X-API-Key: sk-dev-key-001" -H "Content-Type: application/json" -d '{"text1":"test","text2":"check"}'; done

You should see the first 10 lines print 200 and the last 2 lines print 429. The rate limiter counted each request against your key's quota and rejected the ones that exceeded 10 per 60-second window.

What about the Retry-After header?

Each 429 response includes a Retry-After: 60 header. This tells the client exactly how many seconds to wait before retrying. Production HTTP clients and libraries use this value to back off automatically.

Reset your rate limit for further testing

If you want to test again without waiting 60 seconds, flush the Redis key by running:

docker exec -it $(docker compose ps -q redis) redis-cli DEL rate_limit:sk-dev-key-001

All 12 returning 200?

  • Confirm RATE_LIMIT_REQUESTS=10 in your .env file. If this value is higher, more requests pass before triggering 429.
  • Make sure Redis is actually reachable. Run python -c "import redis; r = redis.Redis(); print(r.ping())" and confirm it prints True.
  • If you ran tests earlier, your sorted set may already have entries. Flush it with the Redis CLI command in the tip above, then re-run the loop.

Still stuck? Why is my rate limiter not returning 429 after 10 requests?

Your API now throttles abusive clients automatically. Every request is tracked per key, and the response headers let well-behaved clients pace themselves. Next up, you'll add security headers and input validation to complete the hardening.

Harden Security Headers and CORS

Your API now authenticates callers and throttles abusive ones. But every response it sends is missing headers that protect against common web attacks like clickjacking, MIME sniffing, and protocol downgrades.

In this step, you will add security headers to every response, configure a CORS policy so browsers can safely call your API, and add input validation to block oversized payloads before they reach your ML model.

In this step, get ready to:
  • Add security headers middleware that attaches protective headers to every response.
  • Configure CORS and Pydantic input validation to control who calls your API and what they can send.
  • Verify the full security stack works together end-to-end.
Add security headers middleware

Security headers instruct browsers on how to handle your API's responses. Without them, responses are vulnerable to attacks like clickjacking (embedding your page in a malicious iframe) and MIME-type confusion.

  • Create a new file called app/middleware.py and add the following code:
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware


class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        response = await call_next(request)
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "DENY"
        response.headers[
            "Strict-Transport-Security"
        ] = "max-age=31536000; includeSubDomains"
        response.headers["Cache-Control"] = "no-store"
        return response

What do these headers do?

  • X-Content-Type-Options: nosniff prevents browsers from guessing the content type, blocking MIME-sniffing attacks.
  • X-Frame-Options: DENY blocks your API from being embedded in an iframe, preventing clickjacking.
  • Strict-Transport-Security tells browsers to always use HTTPS when connecting to your API.
  • Cache-Control: no-store prevents sensitive API responses from being cached by proxies or browsers.
  • Save app/middleware.py.
  • Your uvicorn server with --reload should detect the new file automatically. Test the headers by running:
curl -I http://localhost:8000/health

You will not see any security headers yet. The middleware file exists, but it is not wired into the application. That happens in the next substep.

Server not running?

If your server stopped since the last step, restart it by running uvicorn app.main:app --reload from your project folder.

help me restart my uvicorn server

Configure CORS and input validation

CORS controls which browser-based clients can call your API. Input validation prevents oversized payloads from wasting ML inference compute. Both go in app/main.py.

  • In app/main.py, find the imports section at the top of the file:
from contextlib import asynccontextmanager

from fastapi import Depends, FastAPI
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity

from app.auth import verify_api_key
from app.rate_limiter import RateLimiterMiddleware
  • Replace the entire imports section with this updated version, which adds CORSMiddleware, Field, settings, and SecurityHeadersMiddleware:
from contextlib import asynccontextmanager

from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity

from app.auth import verify_api_key
from app.config import settings
from app.middleware import SecurityHeadersMiddleware
from app.rate_limiter import RateLimiterMiddleware
  • Now find the single middleware line below app = FastAPI(title="AI Inference API", lifespan=lifespan):
app.add_middleware(RateLimiterMiddleware)
  • Replace that single line with the full middleware stack:
# SecurityHeaders is innermost - attaches to every response
app.add_middleware(SecurityHeadersMiddleware)
# RateLimiter sits in the middle - checks quota
app.add_middleware(RateLimiterMiddleware)
# CORS is outermost - handles preflight OPTIONS first
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins.split(","),
    allow_methods=["GET", "POST"],
    allow_headers=["X-API-Key", "Content-Type"],
    expose_headers=[
        "X-RateLimit-Limit",
        "X-RateLimit-Remaining",
        "Retry-After",
    ],
)

Why does middleware order matter?

In FastAPI, the last add_middleware call becomes the outermost layer. Requests pass through layers from outermost to innermost.

CORS goes outermost so browser preflight OPTIONS requests are handled immediately without hitting rate limiting. Security headers go innermost so they attach to every response regardless of which middleware short-circuits.

  • Save app/main.py and test the CORS preflight by running:
curl -X OPTIONS http://localhost:8000/similarity -H "Origin: http://example.com" -H "Access-Control-Request-Method: POST"

You should see a 200 response with CORS headers like access-control-allow-origin and access-control-allow-methods in the output.

  • Also confirm security headers are now present by running:
curl -I http://localhost:8000/health

You should see X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Strict-Transport-Security in the response headers.

Now let's add input validation. Find the SimilarityRequest class in app/main.py:

  • Find these lines:
class SimilarityRequest(BaseModel):
    text1: str
    text2: str
  • Replace the two field lines with Field constraints that limit input to 1000 characters:
class SimilarityRequest(BaseModel):
    text1: str = Field(..., min_length=1, max_length=1000)
    text2: str = Field(..., min_length=1, max_length=1000)

Why cap input at 1000 characters?

ML models like sentence-transformers tokenize inputs. Extremely long strings consume disproportionate memory and compute time. Rejecting oversized inputs at the validation layer means your model never wastes resources on abusive payloads.

  • Save app/main.py and test with an oversized payload by running:
curl -s -X POST http://localhost:8000/similarity -H "X-API-Key: sk-dev-key-001" -H "Content-Type: application/json" -d '{"text1":"'$(python3 -c "print('a'*1001")"')'","text2":"short"}' | python3 -m json.tool

You should see a 422 validation error response indicating the string is too long. The oversized payload never reached your ML model.

Not seeing the 422 error?

Make sure you added Field to the import line: from pydantic import BaseModel, Field. Without importing Field, the constraints will not apply.

help me debug my Pydantic Field validation

✔️ Awesome, I've got everything!

Great. Make sure you have saved both files before moving on to the final verification.

ⓧ I'd like to double check the full code

Your app/middleware.py should contain the full SecurityHeadersMiddleware class from substep 4.1 above.

Your app/main.py should have, in order:

  • The updated imports (including CORSMiddleware, Field, settings, and SecurityHeadersMiddleware).
  • The full three-middleware stack (SecurityHeaders, RateLimiter, CORS) with all CORS parameters.
  • The SimilarityRequest class using Field(..., min_length=1, max_length=1000) on both fields.
  • The rest of the file (SimilarityResponse, /health, /similarity) unchanged.
Verify the complete security stack

All four security layers are now in place. Before running the final checks, predict what you expect to see for each scenario: an unauthenticated request, a rate-limited request, a request to the health endpoint, and an oversized payload.

  • Verify that unauthenticated requests still get a 403 by running:
curl -s -X POST http://localhost:8000/similarity -H "Content-Type: application/json" -d '{"text1":"hello","text2":"world"}' | python3 -m json.tool

You should see a 403 response with the message about a missing API key.

  • Confirm security headers appear on every response by running:
curl -I http://localhost:8000/health

You should see x-content-type-options: nosniff, x-frame-options: DENY, strict-transport-security: max-age=31536000; includeSubDomains, and cache-control: no-store among the headers.

  • Test a valid authenticated request to confirm everything still works together by running:
curl -s -X POST http://localhost:8000/similarity -H "X-API-Key: sk-dev-key-001" -H "Content-Type: application/json" -d '{"text1":"hello","text2":"world"}' | python3 -m json.tool

You should see a 200 response with a similarity score. Your complete security stack is working: authentication gates access, rate limiting throttles abuse, security headers harden every response, and input validation blocks oversized payloads.

Seeing an import error on startup?

If uvicorn shows an ImportError for SecurityHeadersMiddleware or settings, check that your import line says from app.middleware import SecurityHeadersMiddleware and that your app/middleware.py file is saved in the same app/ folder.

help me fix my middleware import error

Your API is now production-hardened. It authenticates callers, rate-limits abuse, injects security headers, enforces CORS, and validates inputs. Next up, you will tackle the Secret Mission and build tiered rate limits for different API key tiers.

Secret mission

Add Tiered Rate Limits

Real production APIs don't treat every client the same. Free-tier keys get lower quotas while premium keys get higher limits. Can you modify the rate limiter to apply different limits based on the key's tier prefix?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a Redis Docker container, so there are no ongoing cloud costs.

Resources you used:

  • Redis Docker container (running locally on port 6379).
  • Python packages added to your project: redis, pydantic-settings, python-dotenv.
  • New project files: docker-compose.yml, .env, app/config.py, app/auth.py, app/rate_limiter.py, app/middleware.py.

Keep everything running

No action needed. Choose this if you're still actively building or plan to continue with more projects in this series.

  • Your Redis container continues running in the background, ready for requests.
  • Your FastAPI server can be restarted anytime with uvicorn app.main:app --reload.
  • Redis uses minimal memory when idle (a few MB), so there is no performance concern leaving it running.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping your files and data intact for next time.

  • Stop the Redis container (preserves its data volume) by running:
docker compose stop
  • Stop the uvicorn dev server by pressing Ctrl+C in the terminal where it is running.
  • When you return, restart everything with docker compose start followed by uvicorn app.main:app --reload.

Delete - I don't want to use this again

Remove the Redis container and its data completely. Your project files remain on disk unless you also delete them.

  • Remove the Redis container and its associated network by running:
docker compose down
  • Uninstall the Python packages added in this project by running:
pip uninstall redis pydantic-settings python-dotenv -y
  • To remove the security files added in this project, delete docker-compose.yml, .env, app/config.py, app/auth.py, app/rate_limiter.py, and app/middleware.py by running:
rm docker-compose.yml .env app/config.py app/auth.py app/rate_limiter.py app/middleware.py

Nice Work!

Nice Work!

Nice work! You just transformed an open, unprotected ML inference API into a production-hardened service that authenticates callers, throttles abuse, and defends against common web attacks.

You've learned how to:

  • Build API key authentication using FastAPI's APIKeyHeader security utility, rejecting unauthenticated requests with proper 403 error responses.
  • Implement a Redis-backed sliding window rate limiter using sorted sets and pipelining for atomic, sub-millisecond request counting that returns 429 responses with Retry-After headers.
  • Harden every response with production security headers, a strict CORS policy, and Pydantic input validation constraints that block oversized payloads before they reach your ML model.
  • Secret Mission: Implemented tiered rate limits that apply different quotas based on API key role, giving free keys 5 requests per minute and premium keys 30.

Ready to quiz yourself?