Introduction & Lesson Overview

Welcome back! In the previous lessons, you learned how to generate secure API keys, store them safely, and use them to authenticate requests to your FastAPI application. You also saw how to combine API key authentication with JWTs for flexible access control. Now that you have a solid foundation in creating and authenticating API keys, it is time to focus on managing them securely.

In this lesson, you will learn how to list your API keys in a way that protects sensitive information, how to revoke (deactivate) keys when they are no longer needed, and how to protect your API from abuse using rate limiting. These are essential skills for any real-world API, as they help you maintain security, support auditing, and prevent misuse. By the end of this lesson, you will be able to build robust API key management endpoints and understand how to integrate them into your FastAPI application.

Listing API Keys Securely

When building an API key management system, it is important to allow users to view their keys — but you must never expose the full API key after it is created. This is a key security principle: if someone gains access to the list of keys, they should not be able to use them directly.

Let's look at how you can implement a secure listing endpoint. In the example below, the /api/api-keys/list route retrieves all API keys for the authenticated user. Instead of returning the full key, it provides a preview (just the prefix and asterisks), along with metadata such as the key's name, status, and expiration date.

@router.get("/list")
async def list_api_keys(request: Request, db: AsyncSession = Depends(get_db)):
    authorization = request.headers.get('authorization')
    user = await verify_token(authorization, db)
    
    # Capture user_id early
    user_id = user.id
    
    result = await db.execute(
        select(ApiKey)
        .where(ApiKey.userId == user_id)
        .order_by(ApiKey.createdAt.desc())
    )
    api_keys = result.scalars().all()
    
    keys_with_status = []
    for key in api_keys:
        is_expired = datetime.utcnow() > key.expiresAt
        days_until_expiry = (key.expiresAt - datetime.utcnow()).days
        
        status = 'expired' if is_expired else (
            'expiring_soon' if days_until_expiry < 30 else 'active'
        )
        
        keys_with_status.append({
            "id": key.id,
            "name": key.name,
            "keyPreview": "pb_" + "*" * 60,  # Hide the actual key
            "isActive": key.isActive,
            "isExpired": is_expired,
            "expiresAt": key.expiresAt.isoformat(),
            "createdAt": key.createdAt.isoformat(),
            "status": status
        })
    
    return {
        "apiKeys": keys_with_status,
        "totalCount": len(api_keys),
        "activeCount": sum(1 for k in keys_with_status if k["isActive"] and not k["isExpired"])
    }

In this code, the endpoint first fetches all API keys for the current user using SQLAlchemy's async query interface, ordering them by creation date. For each key, it calculates whether the key is expired and how many days remain until expiration. The keyPreview field is set to a string like pb_************************************************************, so the actual key value is never exposed. The status is set to expired, expiring_soon, or active based on the expiration date. The response includes a list of keys with their metadata, as well as counts of total and active keys.

A sample response might look like this:

{
  "apiKeys": [
    {
      "id": 1,
      "name": "My First Key",
      "keyPreview": "pb_************************************************************",
      "isActive": true,
      "isExpired": false,
      "expiresAt": "2024-07-10T12:00:00.000000",
      "createdAt": "2024-06-10T12:00:00.000000",
      "status": "active"
    }
  ],
  "totalCount": 1,
  "activeCount": 1
}

This approach allows users to manage their keys without risking exposure of sensitive information.

Secure API Key Revocation

Sometimes, you need to disable an API key — maybe it was leaked, or it is no longer needed. Instead of deleting the key from the database, it is best practice to deactivate it. This keeps an audit trail, which is important for security and compliance. Deactivated keys can no longer be used, but you still have a record of their existence and history.

Here is how you can implement a revocation endpoint:

@router.delete("/{key_id}")
async def revoke_api_key(key_id: int, request: Request, db: AsyncSession = Depends(get_db)):
    authorization = request.headers.get('authorization')
    user = await verify_token(authorization, db)
    
    # Capture user_id early
    user_id = user.id
    
    result = await db.execute(
        select(ApiKey).where(ApiKey.id == key_id, ApiKey.userId == user_id)
    )
    api_key = result.scalar_one_or_none()
    
    if not api_key:
        raise HTTPException(status_code=404, detail="API key not found")
    
    # Capture values before modifying
    revoked_key_id = api_key.id
    revoked_key_name = api_key.name
    
    # Deactivate instead of deleting for audit trail
    api_key.isActive = False
    await db.commit()
    
    print(f'API key revoked: keyId={revoked_key_id}, userId={user_id}, keyName={revoked_key_name}')
    
    return {
        "message": "API key revoked successfully",
        "revokedKey": {"id": revoked_key_id, "name": revoked_key_name}
    }

In this code, the endpoint looks up the API key by its ID and the current user using SQLAlchemy. If the key is found, it sets isActive to False and commits the change to the database. The action is logged for auditing. The response confirms the revocation and includes the key's ID and name.

A typical response would be:

{
  "message": "API key revoked successfully",
  "revokedKey": {
    "id": 1,
    "name": "My First Key"
  }
}

By deactivating rather than deleting, you ensure that you can always review which keys existed and when they were revoked.

Implementing Rate Limiting For API Key Requests

As your API grows, it is important to protect it from abuse. One common attack is to flood your API with requests, which can slow down or even crash your service. Rate limiting helps prevent this by restricting how many requests a user or API key can make in a given time period.

In Python/FastAPI applications, you can use the slowapi library to implement rate limiting. In this example, you configure rate limiting to allow each API key 100 requests per hour. The middleware checks if the request is authenticated with an API key and applies the limit accordingly. JWT and unauthenticated requests skip rate limiting entirely, as they typically represent interactive users who are less likely to abuse the API.

The slowapi library automatically adds dynamic rate limit headers to responses: X-RateLimit-Limit (total allowed requests), X-RateLimit-Remaining (requests left in the window), and X-RateLimit-Reset (timestamp when the limit resets). These headers are calculated in real-time based on the actual request count, helping API consumers understand their usage and avoid hitting the limit.

Here is the rate limiting setup:

from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from fastapi import Request
from fastapi.responses import JSONResponse
import uuid

# Custom key function for API key rate limiting
def get_api_key_identifier(request: Request):
    # Rate limiting only applies to API key authenticated requests
    if hasattr(request.state, 'auth_method') and request.state.auth_method == 'api_key':
        return f"api_key_{request.state.api_key_id}"
    # Skip rate limiting for non-API-key requests by returning unique bypass keys
    return f"bypass_{uuid.uuid4()}"

limiter = Limiter(
    key_func=get_api_key_identifier,
    default_limits=[],
    headers_enabled=True  # Enable automatic dynamic headers
)

# Custom error handler for rate limit exceeded
@app.exception_handler(RateLimitExceeded)
async def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded):
    print(f'Rate limit exceeded: api_key_id={getattr(request.state, "api_key_id", None)}, '
          f'ip={request.client.host}, user_agent={request.headers.get("user-agent")}')
    
    return JSONResponse(
        status_code=429,
        content={
            "error": "Rate limit exceeded",
            "message": "Too many requests. Limit: 100 per hour",
            "retryAfter": "1 hour"
        }
    )

This setup uses a custom key function that only generates a rate limit key for API key authenticated requests. The get_api_key_identifier function checks if the request has an API key authentication method set, and if so, returns a unique identifier for that key. For JWT or unauthenticated requests, it returns a unique bypass key using uuid.uuid4(), which effectively skips rate limiting for those requests since each request gets a different key.

When a client makes API key requests, they will see dynamic headers in the response:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 94
X-RateLimit-Reset: 1699564800

These headers decrement with each request, showing the client exactly how many requests they have left and when the limit resets. However, since we only want API key requests to show rate limit information, we need additional middleware to remove these headers from JWT and unauthenticated responses:

from starlette.middleware.base import BaseHTTPMiddleware

class RateLimitHeadersMiddleware(BaseHTTPMiddleware):
    """
    Middleware to remove rate limit headers from non-API-key requests.
    slowapi adds dynamic headers to all requests, but we only want them on API key requests.
    """
    async def dispatch(self, request: Request, call_next):
        response = await call_next(request)
        
        # Remove rate limit headers for non-API-key requests
        if not (hasattr(request.state, 'auth_method') and request.state.auth_method == 'api_key'):
            for header in ['x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset',
                          'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset']:
                if header in response.headers:
                    del response.headers[header]
        
        return response

If a user exceeds the limit, they receive a 429 error:

{
  "error": "Rate limit exceeded",
  "message": "Too many requests. Limit: 100 per hour",
  "retryAfter": "1 hour"
}

This helps protect your API from accidental or malicious overuse by automated systems using API keys, while providing clear, real-time feedback about usage limits through dynamic headers.

Integrating Management Endpoints Into FastAPI

Now that you have endpoints for listing and revoking API keys, and a rate limiting setup, you need to integrate them into your main FastAPI application. This ensures that all API routes are protected and that key management is available to authenticated users.

Here is how the routes and middleware are set up:

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from .routes import auth, snippets, admin, api_keys
from .middleware.authenticate_request import authenticate_request
from slowapi import Limiter
from slowapi.util import get_remote_address

app = FastAPI()

# Setup rate limiter
limiter = Limiter(key_func=get_api_key_identifier)
app.state.limiter = limiter

# Add CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Add authentication middleware
@app.middleware("http")
async def authentication_middleware(request: Request, call_next):
    if request.url.path.startswith("/api"):
        await authenticate_request(request)
    response = await call_next(request)
    return response

# Include routers
app.include_router(auth.router, prefix="/api/auth")
app.include_router(api_keys.router, prefix="/api/api-keys")
app.include_router(snippets.router, prefix="/api/snippets")
app.include_router(admin.router, prefix="/api/admin")

In this setup, the authentication middleware handles authentication for all /api routes, setting the request.state.auth_method and request.state.api_key_id attributes that the rate limiting depends on. The rate limiter is configured to only apply limits to API key authenticated requests, while JWT and unauthenticated requests skip rate limiting entirely. This structure keeps your application organized and secure, ensuring that automated API access is protected by rate limiting while interactive users can work without restrictions.

Summary & Next Steps

In this lesson, you learned how to manage API keys securely in your FastAPI application. You saw how to list API keys without exposing sensitive information, how to revoke keys safely for audit purposes, and how to protect your API from abuse using rate limiting that specifically targets API key requests while allowing interactive users unrestricted access. You also learned how to integrate these features into your main FastAPI application for a clean and secure architecture.

These skills are essential for any API that uses key-based authentication. They help you keep your users safe, support compliance, and maintain the reliability of your service. In the next set of practice exercises, you will get hands-on experience with these concepts, reinforcing what you have learned and preparing you to build secure, production-ready APIs. Remember, on CodeSignal, all the necessary libraries are pre-installed, so you can focus on writing and testing your code. Good luck, and keep building your security skills!

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal