A simple Python FastAPI template with API key authentication

A simple Python FastAPI template with API key authentication

·

8 min read

I’ve been dusting off my Python recently and building some APIs with FastAPI. I had a pretty simple use case - a backend API that uses API keys for authentication. However, I couldn’t find a straightfoward tutorial. The FastAPI docs are very good but a little too in-depth; for example they bundle the creation of OAuth bearer tokens into the same code as the backend itself. So I wanted to create a template for myself that simplifies the authentication, abstracts the database stuff, and also follows a reasonable best-practices approach to project layout, modules, packages and so on. So here it is! But be warned; like I said, I’m still rusty. If you spot anything obviously terrible in the code below, please let me know (nicely).

Project layout

We’ll start with a simple Python project layout for a FastAPI app.

.
├── app
│   ├── __init__.py
│   ├── auth.py
│   ├── db.py
│   ├── main.py
│   └── routers
│       ├── __init__.py
│       ├── public.py
│       ├── secure.py
├── venv

Hopefully this makes sense if you’re fairly comfortable with Python. app is our Python package in this case, main is our main module, while auth and db are utility modules in the package. routers is a subpackage containing the public and secure submodules. There’s empty __init__.py files aplenty to tell Python this is a proper package, and in my case I also have a venv directory for my virtual environment, although that isn’t required. Now let’s go into what each of these files actually does.

main.py

Here’s our main module:

from fastapi import FastAPI, Depends
from routers import secure, public
from auth import get_user

app = FastAPI()

app.include_router(
    public.router,
    prefix="/api/v1/public"
)
app.include_router(
    secure.router,
    prefix="/api/v1/secure",
    dependencies=[Depends(get_user)]
)

We start by importing the modules we’ll need from FastAPI, namely itself and the Depends module. From our own auth module we import a function called get_user which we’ll explain in a moment. From our routers subpackage we import secure and public. And we create our app with app = FastAPI().

So what are these routers? Well, in a simple FastAPI app we could just start defining paths and responses in our main program. Here’s a super simple example:

@app.get("/")
async def root():
    return {"message": "Hello World"}

In 3 lines we can specify that we want to return “Hello World” for every GET request to / (asynchronously to boot!). But this doesn’t scale very well.

A router is a separate set of paths and functions grouped together. They’re useful for grouping features of a much larger API together into logical pieces. In this tutorial, we’ll create two groups of paths: secure for paths that require an API key, and public for those that don’t.

Let’s look at this piece again:

app.include_router(
    public.router,
    prefix="/api/v1/public"
)

Here we call the include_router function on our app, and pass in 2 paramters. The first, public.router is the router object from the public module. The second prefix, defines where these paths sit in the context of our overall API app. As you can see, routers make it easy to change URL prefixes and even introduce API versioning.

The next one is a bit more complex:

app.include_router(
    secure.router,
    prefix="/api/v1/secure",
    dependencies=[Depends(get_user)]
)

Now we’re using the router object from the secure module, and we’ve added a dependency using FastAPI’s Depends function. The dependency is on the get_user function, that we’ve defined in the auth module. As you can probably guess, that means that all paths in this router depend on that function. So let’s take a look at it.

auth.py

from fastapi import Security, HTTPException, status
from fastapi.security import APIKeyHeader
from db import check_api_key, get_user_from_api_key

api_key_header = APIKeyHeader(name="X-API-Key")

def get_user(api_key_header: str = Security(api_key_header)):
    if check_api_key(api_key_header):
        user = get_user_from_api_key(api_key_header)
        return user
    raise HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Missing or invalid API key"
    )

First we import some more useful stuff from FastAPI, including methods for dealing with headers and HTTP exceptions. They’ll make more sense when we get to the part that actually uses them in a moment. And yes, I do appreciate that this is a bit circular, because next we’re importing functions from db which we haven’t seen yet. But before we get into that file, just know that check_api_key returns True if an API key is valid, and get_user_from_api_key returns a user object from a valid API key.

The main purpose of the auth module is to provide us with the get_user function, which as we saw in the previous file, our secure routes depend on. This function accepts one parameter - the value of X-API-Key that has been sent in the request. Using the APIKeyHeader and Security functions in FastAPI allows us to define the header name for our API key (and therefore populate this automatically in our OpenAPI documentation) and extract it from the header.

With the API key stored in api_key_header, we next call our check_api_key function (again, we’ll see how that works in a moment). If the response is True, we proceed to get some user data by calling get_user_from_api_key and then return it.

If check_api_key returned False, then the API key we checked wasn’t valid. So we use FastAPI’s HTTPException to return a suitable response, in this case a 401 error.

Now we know what has to happen for every path that depends on get_user. Before we look at those paths and routes, let’s quickly explore db.py, which is where we defined check_api_key and get_user_from_api_key.

db.py

This file is basically a placeholder for whatever backend database integration you want to use. Right now, it implements the methods we needs, and contains a hard-coded set of API keys and users.

api_keys = {
    "e54d4431-5dab-474e-b71a-0db1fcb9e659": "7oDYjo3d9r58EJKYi5x4E8",
    "5f0c7127-3be9-4488-b801-c7b6415b45e9": "mUP7PpTHmFAkxcQLWKMY8t"
}

users = {
    "7oDYjo3d9r58EJKYi5x4E8": {
        "name": "Bob"
    },
    "mUP7PpTHmFAkxcQLWKMY8t": {
        "name": "Alice"
    },
}

def check_api_key(api_key: str):
    return api_key in api_keys

def get_user_from_api_key(api_key: str):
    return users[api_keys[api_key]]

I’m sure I don’t need to tell you - don’t do this in production! Instead, you should incorporate whatever database connections and query methods you need to to acquire API keys and user details from your database (how you actually create those keys is another discussion). The actual data structures may be very different as well.

For now, we have a simple dict of key:value pairs for api_keys where the key is the actual API key, and the value is the user ID. I’m using long UUIDs for API keys, and short UUIDs for user IDs, but again this could be something very different. For the users we have another dict where the key is the user ID, and the value is another dict to hold all the important user data. Right now we’re just storing a name.

check_api_key simply checks that the API key exists in the list of keys it knows about. Again, you’d probably need to change this for some sort of DB lookup in production.

get_user_from_api_key then uses the API key to get a corresponding user ID, and returns the nested user dict.

So far we have all of our basic functionality in terms of API authentication. Now we just need some routes!

routers/public.py

from fastapi import APIRouter

router = APIRouter()

@router.get("/")
async def get_testroute():
    return "OK"

This is about as simple as it gets. In this module, all we do is define a router object, with a single path. When a request is made to /. it will return "OK". Don’t forget, the actual path is relative, and if you refer back to main.py you’ll see we set up this router to run under /api/v1/public.

routers/secure.py

from fastapi import APIRouter, Depends
from auth import get_user

router = APIRouter()

@router.get("/")
async def get_testroute(user: dict = Depends(get_user)):
    return user

The secure routes are only slightly more complex. Just like in main.py we’re using FastAPI’s Depends to specify that we are dependent on a function, in this case: get_user. So we know this route is guaranteed secure, and can only be accessed by someone with a valid API key, because without one you won’t get through the get_user function without triggering the HTTP Not Authorized error we set up earlier. If you have a valid key, for now we just return the user object.

Setup

This may vary based on your own development preferences, but the quickest way I find to set up a FastAPI app is to create a virtualenv, install the FastAPI package, and then use Uvicorn to run the app. You may have noticed the venv directory earlier - I created this with:

python -mvenv venv

I then activated the virtual environment and installed FastAPI:

source venv/bin/active
pip install "fastapi[standard]"

Finally, to run my FastAPI app, I can now run this command from the app directory:

uvicorn main:app --reload

The --reload option will reload my code automatically if I make any changes. If everything has worked for you, you should be able to access your app at http://127.0.0.1:8000/

Testing

First we sent a GET request with no API key to the public endpoint /api/v1/public/:

A GET request to /api/v1/public/ with no API key header returns an 'OK' response.

Now let’s try the secure endpoint at /api/v1/secure/:

A GET request to /api/v1/secure/ with no API key header returns an HTTP 403 error.

The API returns a 403 error. Note, this isn’t the 401 error we coded for when an API key isn’t found in the database. FastAPI can’t even find an API key, because we haven’t specified one, so immediately returns the 403 Forbidden error.

Let’s try a random API key that is not in the database:

A GET request to /api/v1/secure/ with an invalid API key header returns an HTTP 401 error.

This time, we’ve supplied an X-API-Key header, but the key we’re supplying isn’t in the database. So we return a 401 Unauthorized error (technically, we’re unauthenticated but these error codes were written a long time ago!)

Finally, let’s try a working API key, for the user Bob:

A GET request to /api/v1/secure/ with an valid API key header successfully returns a username.

Success! The API key is found, checked and proven valid, and the user details are retrieved and used in the response.

What’s next?

This is hopefully some helpful project boilerplate to get you started on your own FastAPI app. Don’t forget, we’ve hardcoded a dummy database in db.py, so connecting the authentication up to a real database should be the next thing we do. I’ll try to tackle this in the next post!

From there, we could continue to implement different routes and paths, which brings us onto the topics that come after authentication - authorisation and/or admission control.

Let me know what you think in the comments!