API Key Management and Validation
API keys are simpler than JWT or OAuth2: a long random string that identifies an app or service. Unlike JWTs (which contain claims), API keys are opaque tokens stored in a backend database and looked up on each request. They excel for service-to-service authentication, public API tiers (free/pro), and legacy systems. However, they lack expiration and scopes, making them less secure than OAuth2 for delegated access.
API Keys vs. OAuth2 vs. JWT
| Aspect | API Key | JWT | OAuth2 |
|---|---|---|---|
| Opaque | Yes (lookup required) | No (self-contained) | Varies |
| Revocation | Instant | Hard (until expiry) | Instant |
| Scopes | No (coarse-grained) | Yes | Yes |
| Expiration | No (manual rotation) | Yes | Yes |
| Use Case | Service-to-service, public APIs | Stateless APIs | User delegation |
| Database Lookup | Per request | No | No |
Generating Secure API Keys
API keys should be cryptographically random, long (32+ characters), and URL-safe. Never use sequential IDs or predictable patterns:
# key_generation.py
import secrets
import string
def generate_api_key(prefix: str = "sk", length: int = 32) -> str:
"""Generate a secure random API key."""
# Use base64-safe characters
alphabet = string.ascii_letters + string.digits + "-_"
random_part = "".join(secrets.choice(alphabet) for _ in range(length))
return f"{prefix}_{random_part}"
# Example: "sk_vXkR2n-8qLpM9wJ_FgH3BcDe4aI5jK"
key = generate_api_key()
Storing API Keys Securely
Never store keys in plaintext. Hash them with bcrypt (same as passwords) before storing in the database:
# main.py
from passlib.context import CryptContext
from sqlalchemy import Column, String, Boolean, DateTime
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
Base = declarative_base()
class APIKey(Base):
__tablename__ = "api_keys"
id = Column(String, primary_key=True) # UUID
key_hash = Column(String, unique=True) # Bcrypt hash
name = Column(String) # Human-readable (e.g., "Production API Key")
owner_id = Column(String) # User or service ID
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
last_used_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True) # Optional expiration
def hash_api_key(key: str) -> str:
"""Hash an API key for storage."""
return pwd_context.hash(key)
def verify_api_key(plain_key: str, hashed_key: str) -> bool:
"""Verify plaintext key against stored hash."""
return pwd_context.verify(plain_key, hashed_key)
When creating an API key, hash and store it:
@app.post("/api-keys")
async def create_api_key(
current_user: dict = Depends(get_current_user),
name: str = "My API Key"
):
"""Create a new API key for the current user."""
raw_key = generate_api_key()
key_hash = hash_api_key(raw_key)
# Store hash in database
api_key = APIKey(
id=str(uuid.uuid4()),
key_hash=key_hash,
name=name,
owner_id=current_user["username"]
)
db.add(api_key)
db.commit()
# Return raw key ONCE (user must save it)
return {
"key": raw_key,
"note": "Save this key now; it won't be shown again.",
"id": api_key.id
}
Validating API Keys on Routes
Create a dependency that extracts and validates the key from the Authorization header or query parameter:
# security.py
from fastapi import HTTPException, Header, Depends, status
from typing import Annotated
from sqlalchemy.orm import Session
async def get_api_key(
authorization: Annotated[str | None, Header()] = None,
api_key: Annotated[str | None, None] = None # Query param fallback
) -> str:
"""Extract API key from header or query."""
key = None
if authorization and authorization.startswith("Bearer "):
key = authorization[7:] # Remove "Bearer " prefix
elif api_key:
key = api_key
if not key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key"
)
return key
async def verify_api_key_dependency(
db: Session = Depends(get_db),
key: str = Depends(get_api_key)
) -> dict:
"""Verify API key exists and is active."""
# Find all keys, hash incoming key, compare hashes
api_keys = db.query(APIKey).filter(APIKey.is_active == True).all()
for stored_key in api_keys:
if verify_api_key(key, stored_key.key_hash):
# Update last used timestamp
stored_key.last_used_at = datetime.utcnow()
db.commit()
return {
"owner_id": stored_key.owner_id,
"key_id": stored_key.id,
"key_name": stored_key.name
}
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key"
)
Use it on protected routes:
@app.get("/protected")
async def protected_endpoint(
api_key_info: dict = Depends(verify_api_key_dependency)
):
"""Protected by API key."""
return {
"message": f"Hello {api_key_info['owner_id']}",
"key_name": api_key_info['key_name']
}
Implementing Key Rotation
API keys should be rotated periodically (every 90 days is common). Old keys can be disabled without immediately breaking integrations:
@app.patch("/api-keys/{key_id}")
async def rotate_api_key(
key_id: str,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new key and optionally disable the old one."""
old_key = db.query(APIKey).filter(
APIKey.id == key_id,
APIKey.owner_id == current_user["username"]
).first()
if not old_key:
raise HTTPException(status_code=404, detail="Key not found")
# Generate new key
new_raw_key = generate_api_key()
new_hash = hash_api_key(new_raw_key)
new_key = APIKey(
id=str(uuid.uuid4()),
key_hash=new_hash,
name=f"{old_key.name} (rotated)",
owner_id=old_key.owner_id
)
db.add(new_key)
# Disable old key (or delete)
old_key.is_active = False
db.commit()
return {
"new_key": new_raw_key,
"old_key_id": old_key.id,
"status": "rotated"
}
Enforcing Key Expiration
Optional expiration adds defense-in-depth:
from datetime import datetime, timedelta
@app.post("/api-keys")
async def create_api_key(
current_user: dict = Depends(get_current_user),
name: str = "My API Key",
expires_in_days: int | None = 90, # Optional expiration
db: Session = Depends(get_db)
):
"""Create API key with optional expiration."""
raw_key = generate_api_key()
expires_at = None
if expires_in_days:
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
api_key = APIKey(
id=str(uuid.uuid4()),
key_hash=hash_api_key(raw_key),
name=name,
owner_id=current_user["username"],
expires_at=expires_at
)
db.add(api_key)
db.commit()
return {"key": raw_key, "expires_at": expires_at}
async def verify_api_key_dependency(
db: Session = Depends(get_db),
key: str = Depends(get_api_key)
) -> dict:
"""Verify key validity and expiration."""
api_keys = db.query(APIKey).filter(APIKey.is_active == True).all()
for stored_key in api_keys:
if verify_api_key(key, stored_key.key_hash):
# Check expiration
if stored_key.expires_at and stored_key.expires_at < datetime.utcnow():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key has expired"
)
stored_key.last_used_at = datetime.utcnow()
db.commit()
return {"owner_id": stored_key.owner_id, "key_id": stored_key.id}
raise HTTPException(status_code=401, detail="Invalid API key")
Rate Limiting by API Key
Track usage per key to enforce API tiers (free plan: 100 req/day, pro: 10k req/day):
from fastapi_limiter import FastAPILimiter
from fastapi_limiter.util import get_remote_address
# Configure limiter with key-based identifier
FastAPILimiter.init(redis_client)
@app.get("/data")
@limiter.limit("100/day") # Or per-user limit
async def get_data(
current_key_info: dict = Depends(verify_api_key_dependency)
):
# Limiter uses current_key_info['key_id'] as identifier
return {"data": "..."}
Key Takeaways
- API keys are opaque, hashed tokens ideal for service-to-service and public API authentication.
- Hash keys with bcrypt before storage; validate by hashing and comparing on each request.
- Keys lack expiration and scopes, making them less secure than JWT or OAuth2 for user delegation.
- Rotation, expiration dates, and last-used tracking prevent key misuse.
- Monitor for compromised keys via usage analytics and rate limiting.
Frequently Asked Questions
Should I use API keys or JWT for my public API?
For third-party developers accessing your API, API keys are simpler and more familiar. For user sessions, JWT is better. For OAuth2 integrations (delegated access), use OAuth2. Many APIs support multiple methods.
How many keys should a user have?
Allow multiple keys (one per integration/service). This limits blast radius if a key is compromised. Each key can be independently rotated or revoked.
Can I use the same API key across environments?
No. Generate separate keys for dev, staging, and production. This prevents accidental leaks of production credentials and enables granular revocation.
What if someone leaks a key?
Delete it immediately from the database. Implement immediate revocation (set is_active = False) and monitor the key for unusual activity before deletion. If a key was public for days, rotate all keys as precaution.