n4nAI

How to add API key auth to a FastAPI LLM backend

Step-by-step FastAPI API key authentication for LLM backends: issue hashed keys, validate via dependency, enforce rate limits, and verify.

n4n Team3 min read718 words

Audio narration

Coming soon — every post will get a voice note here.

Most teams stand up an LLM proxy without auth, then scramble when a stranger drains their quota. This guide implements fastapi api key authentication llm backend patterns that survive contact with production: issuing opaque keys, validating them in a dependency, and scoping each key to a tenant and rate limit. You will end with a small but real auth layer in front of any OpenAI-compatible chat endpoint.

Step 1: Model API keys as hashed tokens

Never store raw keys. Generate an opaque random string, store only its SHA-256 hash, and return the plaintext exactly once at creation time. Unlike user passwords, API keys have 256+ bits of entropy from secrets.token_urlsafe, so a fast hash like SHA-256 is appropriate—you do not need bcrypt or argon2.

Use a simple table. If you already have Postgres, swap SQLite for your engine and add migrations.

import sqlalchemy as sa
from sqlalchemy.orm import declarative_base

Base = declarative_base()

class ApiKey(Base):
    __tablename__ = "api_keys"
    id = sa.Column(sa.Integer, primary_key=True)
    key_prefix = sa.Column(sa.String(8), index=True)  # for human lookup
    key_hash = sa.Column(sa.String(64), unique=True, nullable=False)
    tenant_id = sa.Column(sa.String(64), index=True)
    revoked = sa.Column(sa.Boolean, default=False)
    created_at = sa.Column(sa.DateTime, server_default=sa.func.now())

The key_hash is hex-encoded SHA-256 (64 chars). The key_prefix is the first few chars of the raw key so support can identify a key without exposing it. The tenant_id binds the key to a workspace or customer.

Step 2: Generate and issue keys

Expose an admin endpoint to mint keys. Use secrets.token_urlsafe for entropy, hash with hashlib, and persist. Return the raw key exactly once; after that, only the hash exists in your store.

import secrets, hashlib
from fastapi import FastAPI, Depends, HTTPException

app = FastAPI()

def hash_key(raw: str) -> str:
    return hashlib.sha256(raw.encode()).hexdigest()

@app.post("/admin/keys/{tenant_id}")
def create_key(tenant_id: str, db: sa.orm.Session = Depends(get_db)):
    raw = secrets.token_urlsafe(32)
    db.add(ApiKey(
        key_prefix=raw[:8],
        key_hash=hash_key(raw),
        tenant_id=tenant_id,
    ))
    db.commit()
    return {"api_key": raw, "tenant_id": tenant_id, "prefix": raw[:8]}

Any caller of this admin route must itself be locked down—put it behind your real admin auth (OAuth, mTLS, or a separate internal network), not the API key scheme you are building. Treat the admin route as a credential issuer, because that is exactly what it is.

Step 3: Build the FastAPI auth dependency

FastAPI ships APIKeyHeader and HTTPBearer. Header-based X-API-Key is simpler for server-to-server LLM calls. Validate the presented key against the hash store. Load the tenant only if the key exists and is not revoked.

from fastapi.security import APIKeyHeader
from fastapi import Security

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

def get_current_tenant(api_key: str = Security(api_key_header), db: sa.orm.Session = Depends(get_db)) -> str:
    if not api_key:
        raise HTTPException(status_code=401, detail="Missing API key")
    record = db.query(ApiKey).filter_by(key_hash=hash_key(api_key), revoked=False).first()
    if not record:
        raise HTTPException(status_code=401, detail="Invalid or revoked API key")
    return record.tenant_id

This dependency returns a tenant_id string you can inject into downstream calls. It runs before your LLM route logic, so unauthorized requests never reach the model. If you prefer bearer tokens, swap APIKeyHeader for HTTPBearer and read credentials.credentials.

Step 4: Protect the LLM endpoint

Wire the dependency into your chat route. The example calls an OpenAI-compatible server; swap the base URL for your provider. If you route through a gateway like n4n.ai, it honors client routing directives and forwards provider cache-control hints, so you can pass through tenant-specific headers after key validation.

import openai  # openai>=1.0

client = openai.OpenAI(base_url="https://your-llm-gateway/v1", api_key="gw_secret")

@app.post("/v1/chat/completions")
def chat(body: dict, tenant_id: str = Depends(get_current_tenant)):
    # tenant_id scopes logs, rate limits, and upstream routing
    resp = client.chat.completions.create(
        model=body.get("model", "gpt-4o-mini"),
        messages=body["messages"],
        temperature=body.get("temperature", 0.7),
    )
    return resp.model_dump()

The Depends(get_current_tenant) is the only auth line needed. Without a valid X-API-Key header, FastAPI returns 401 before the client is constructed. You can also enforce a per-tenant model allowlist here by checking body["model"] against a config map.

Step 5: Add per-key rate limiting

Authentication without rate limiting just lets one valid user bankrupt you. Implement a sliding window with Redis or a simple in-memory dict for single-instance deploys. For production, Redis is mandatory once you run more than one worker.

import time
from collections import defaultdict

_hits = defaultdict(list)
RATE_LIMIT = 10  # requests per window
WINDOW = 60      # seconds

def check_rate_limit(tenant_id: str = Depends(get_current_tenant)):
    now = time.time()
    window = _hits[tenant_id]
    window[:] = [t for t in window if now - t < WINDOW]
    if len(window) >= RATE_LIMIT:
        raise HTTPException(status_code=429, detail="Rate limit exceeded")
    window.append(now)
    return tenant_id

Apply it alongside auth:

@app.post("/v1/chat/completions")
def chat(body: dict, tenant_id: str = Depends(check_rate_limit)):
    ...

For multi-instance, use Redis INCR with EXPIRE or a token bucket library. The pattern stays identical: the dependency either returns the tenant or raises. If you use an upstream gateway with per-token usage metering, you can also bill the tenant based on usage returned in the response.

Step 6: Rotate and revoke keys

Keys leak. Provide revocation and rotation endpoints. Revocation flips the revoked flag; rotation mints a new key and revokes the old one.

@app.delete("/admin/keys/{key_id}")
def revoke_key(key_id: int, db: sa.orm.Session = Depends(get_db)):
    k = db.get(ApiKey, key_id)
    if not k:
        raise HTTPException(status_code=404, detail="Key not found")
    k.revoked = True
    db.commit()
    return {"status": "revoked"}

@app.post("/admin/rotate/{key_id}")
def rotate_key(key_id: int, db: sa.orm.Session = Depends(get_db)):
    old = db.get(ApiKey, key_id)
    if not old:
        raise HTTPException(status_code=404, detail="Key not found")
    old.revoked = True
    raw = secrets.token_urlsafe(32)
    db.add(ApiKey(key_prefix=raw[:8], key_hash=hash_key(raw), tenant_id=old.tenant_id))
    db.commit()
    return {"api_key": raw}

Because validation checks revoked=False, the change takes effect on the next request—no downtime, no cache invalidation. Log the admin action to an audit trail with the key prefix, not the raw key.

Step 7: Verify the integration

Run a local instance and exercise three paths: missing key, bad key, valid key. Use curl for a quick smoke test and pytest for regression safety.

# No key -> 401
curl -X POST localhost:8000/v1/chat/completions -d '{"messages":[]}' -H "Content-Type: application/json"
# Returns 401 {"detail":"Missing API key"}

# Valid key -> 200 (assuming gateway creds set)
KEY=$(curl -X POST localhost:8000/admin/keys/acme | jq -r .api_key)
curl -X POST localhost:8000/v1/chat/completions \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"hi"}]}'

Write a pytest using TestClient to lock the behavior:

from fastapi.testclient import TestClient

def test_missing_key(client: TestClient):
    r = client.post("/v1/chat/completions", json={"messages": []})
    assert r.status_code == 401

def test_valid_key(client: TestClient, raw_key: str):
    r = client.post("/v1/chat/completions", json={"messages": [{"role":"user","content":"hi"}]},
                    headers={"X-API-Key": raw_key})
    assert r.status_code == 200

def test_revoked_key(client: TestClient, raw_key: str, db):
    # revoke the key used to generate raw_key, then assert 401
    ...

If those pass, your fastapi api key authentication llm layer is functioning: requests without a key are blocked, valid keys reach the model, and revocation works instantly. Add a CI job that runs these tests against a ephemeral SQLite database so the auth contract never silently breaks.

Operational notes

Log only the tenant_id and key prefix, never the raw key. Terminate TLS at the edge so X-API-Key travels encrypted. If you forward to an upstream gateway, keep your gateway secret in environment config, not in the repo. The auth layer described here is the difference between a demo and a billable product—without it, any person with your URL spends your money.

Tagsfastapiauthenticationapi-keyssecurity

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All fastapi llm backend integration posts →