Easy, secure API keys

Photo by Christian Lendl on Unsplash

I needed to add API key authentication to our work environment. I needed:

  • To develop the functionality quickly
  • Be confident in the security of the system
    • API keys should only be visible once when created
    • Should be hard to impersonate other users/be resilient to DB leak
  • Have an upgrade path for when we want to change/improve things
  • Performance isn’t a huge consideration

I was able to use JWT tokens in a slightly clever way to make this happen. The best part is, there are no stored secrets on the server side. All of the server data could be revealed publicly without compromising the security. Here’s how:

Creating a user key

I created an endpoint POST /api-key and the endpoint takes in the user_id (via the existing user/password based auth flow), and then some metadata like: name, scopes, expires_at etc.

The server contains a SQL table (keys) with 3 columns: key_id (autoincrement), public_key, and metadata.

Now, to generate an API key, the server first generates a new RSA public/private key pair. Then, it creates a new row in the keys database with the public_key and metadata from the endpoint. Once the data is inserted, then SQL will return the generated key_id index for the row (Let’s say it’s 42). Now we have all the pieces needed to create the api key.

Here’s what the JWT payload looks like. sub = user_id, ver = a versioning scheme for the api keys. kid = key_id (matching SQL), and the remaining fields are just any custom logic needed.

{
  "sub": "my_user_id",
  "ver": 1,
  "kid": 42,
  "exp": max(passed_in_expiry, 1 year from now),
  "scopes": passed_in_scopes
}

To actually issue the JWT, the server uses the private key generated earlier. So now we have 4 artifacts: The signed JWT token, the private key, the public key, and the SQL row inserted. The private key at this point is discarded. It is never to be seen again. The signed JWT token is returned to the user, and the SQL row remains.

Validating the api key

Okay, so the JWT is saved by the user and then sent in later with e.g. Authorization: Bearer <JWT>. Now what? Well, remember that the JWT is signed, but not encrypted so we can read all the payloads. First, we grab the kid to know which row in the database to look up. The database contains the public key from the generation step. Finally, we validate the JWT was signed by that public key. That’s it!

What’s the magic?

Well, the magic is that we generated the JWT once, and then threw away the private key. Imagine if google.com had an SSL certificate, but they lost their private key. Because the public key is known, we could still validate that google.com signed any previous, old messages. However, nobody could create new messages with just the public key. (Otherwise public/private key encryption would be fundamentally broken)

Looking at threat models:

  • What happens if the user generates a different JWT that pretends to be someone else?
    • The public key in the database won’t match
  • What happens if someone leaks the entire keys database (but can’t modify any rows?)
    • We’ll know how many api keys there are etc. However, because neither the JWT nor the private key is stored, nobody can create new api keys
  • What if I need to revoke an api key?
    • Just delete the row from the database
  • What if the user isn’t smart and lets someone else read their api key?
    • The attacker wins, because there’s no session management here

Just to hammer in the point – The database isn’t secret at all. In fact, I could have a GET route that displays the entire database without compromising the security. That’s because the private key is not stored after initial creation