Managing API keys in django

  • Keys are models with their own properties, not just strings.
  • Users may have multiple keys.
  • Deleted keys are retained in the DB.

Users have multiple keys

At some point, one of your customers is going to want to regenerate a new API key. Maybe the old one was exposed in a breach, or they have a policy of rotating all credentials whenever an employee leaves.

If you only have a single key per user (like user.api_key) the customer won’t be able to access your API from the time the new key is generated up until they have deployed a new release with those new credentials on their end. Giving customers two keys means they can create a new key, build and deploy a new release while requests with the old key are still served, then delete the old key once they’ve confirmed it’s no longer being used.

Two keys are enough to get you started. But I’d still recommend against hardcoding that number (like user.api_key_1, user.api_key_2): as your customer-base grows, you’ll eventually get an email from someone who wants more than two keys. Perhaps unique keys for development, staging, and production, plus an extra slot to allow for rotation.

So to begin with, our APIKey model gets a foreign key user property (which allows a user to have many keys), an id separate from the access string (so as not to leak database identifiers), as well as key_value which will store the actual key used for authenticating with the API.

I recommend using TimeStampedModel as a base class for all django models, it adds created and modified timestamps which are super helpful for debugging and also sorting objects in a meaningful way.

from django.db import models
from model_utils.models import TimeStampedModel

class APIKey(TimeStampedModel):
    id = models.AutoField(primary_key=True)
    user = models.ForeignKey(User, on_delete=models.PROTECT)
    key_value = models.CharField(max_length=32, unique=True)

    class Meta:
        ordering = ["created"]

    def __str__(self):
        return self.key_value

Key format

A GPXZ API key looks something like this:

ak_e6YkbUK9Fovx8aErYBPm04

It starts with a fixed ak prefix. This helps keys stand out from other (less important) random strings.

The remainder is a string of random alphanumeric characters. 22 characters gives 130 bits of randomness, which is generally considered sufficient to be unguessable.

This mathematics assumes keys are case sensitive on validation!

There are many ways to generate random strings in Python, but when dealing with security-critical stuff it’s best to outsource to a library that deals with security. In django there’s django.utils.crypto.get_random_string for this.

Putting that all together gives something like this:

from django.utils.crypto import get_random_string

def _generate_api_key(self):
    return get_random_string(22)

class APIKey(TimeStampedModel):
    key_value = models.CharField(
        max_length=32,
        unique=True,
        default=_generate_api_key,
    )
    ...

Note that default=_generate_api_key will fail if added in a migration: the default generator is only evaluated once, tripping the uniqueness constraint. Handling this requires some manual migration management.

Aside: user IDs in API keys

A GPXZ API key actually looks something like this:

ak_5uOU_e6YkbUK9Fovx8aErYBPm04

The extra 5uOU bit is a fixed random unique ID for each user. Including that in the key gives a few tiny benefits:

  • When authenticating a request, both the user details and the key details can be queried in a single round trip to redis.
  • When we get a key attached to a Sentry error, figuring out the impacted user is a simple lookup.

but also has some drawbacks:

  • Key is longer.
  • Extra complexity on key generation and authentication, meaning more places for things to go wrong in security-critical code.
  • You have to be careful not to use any information derived from the user ID until the whole key is authenticated.

In hindsight, the benefits are so small that this approach isn’t one I’d recommend.

Track deleted keys

Customers should be able to delete API keys, for example to prevent access in the case of a breach. But while we do want to prevent those keys from being able to access the application, we don’t want to remove them from the database. Keeping a record of old keys can help debugging historical bugs, and diagnose when customers are using outdated keys.

It also lets us show recently deleted GPXZ API keys, which can help a customer do their own debugging before reaching out to you!




We’ll add a couple new properties to our APIKey model:

class APIKey(TimeStampedModel):
    is_active = models.BooleanField(default=True)
    deactivated_at = models.DateTimeField(null=True)
    ...

    def save(self, *args, **kwargs):
        if not self.is_active and not self.deactivated_at:
            self.deactivated_at = datetime.now(timezone.utc)
        super().save(*args, **kwargs)

then “deleting” a key looks like:

key.is_active = False
key.save()

and you need to filter active keys only when querying for authentication.

API keys aren’t just strings

To start with, the main properties on your API key might be is_active and created.

But over time, you’re likely to need further functionality to keys. For example:

  • Permission scopes to limit the access of keys, e.g. a readonly key for testing.
  • IP / hostname restrictions, e.g. a key that can be used in browser applications that won’t allow cross-origin requests.
  • Rate limits, e.g. so a test key doesn’t cause quota errors in production.
  • A name for each key to keep track of all of these keys with different settings!

Even if you don’t expose this functionality yet, you should convert the input key string to a full APIKey model as early as possible.

api_key_str = find_api_key_str(request)
api_key = APIKey.from_key_value(api_key_str)
api_key.authorise(request)

This also lets you keep all the key logic in one place (as methods on APIKey).

Work with us

When we’re not busy working on GPXZ we help other companies with Python web development. If you need someone to set up your django API, get in touch.