Rotate API keys

A key that has leaked, or that has been in one deployment for a long time, should be replaced. This guide covers the replacement that leaves no window in which calls fail.

Rotate on a schedule you choose, and rotate at once when a key reaches a log, a ticket, a screenshot or a repository.

Understand what identifies a key

A key is shown in full once, at the moment it is created. Copy it then; it cannot be read again from anywhere.

What identifies it afterwards is its prefix: the kind marker plus the first 8 characters of the secret body, as in sk_live_9c41ba2e. The dashboard, the listing and your own records all work from that.

The prefix also carries the kind. A key whose prefix begins sk_sandbox_ is never charged, and the kind is read from those characters rather than looked up anywhere. A key that starts sk_live_ bills.

An account holds up to 10 active keys, which is what makes the overlap below possible.

Overlap, then revoke

Do the three steps in this order. The old key keeps working throughout.

  1. Issue the new key. Open https://doc.cheap/app/keys, create a key of the same kind, and give it a name that says where it is going. Copy the secret.
  2. Deploy it. Put the new secret into the configuration of every service that calls the API, and roll them. Both keys authenticate during this window, so a half-rolled fleet is a fleet that still works.
  3. Revoke the old key. Come back to the key list and revoke the one you replaced. Do it only once nothing sends it any more.

The list shows a Last used column for every key. It is stamped at most once a minute, so a key that has been quiet for an hour has genuinely been quiet. Wait for the old key to go quiet before step 3, and the rotation costs no failed call.

Warning. The Rotate action in the dashboard is not this procedure. It withdraws the old secret at once and issues the replacement in the same step, so every caller still holding the old one starts failing. Use it when a key has leaked and the leak matters more than the gap.

Verify the new key before you revoke anything

Make one call with the new secret and check that it answers 200. Read the key out of the environment rather than pasting it into the code you deploy.

curl

curl -X POST https://api.doc.cheap/v1/scans \
  -H "Authorization: Bearer ${DOC_CHEAP_API_KEY:-sk_sandbox_public}" \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\"}"

JavaScript

import { readFileSync } from "node:fs";

const key = process.env.DOC_CHEAP_API_KEY ?? "sk_sandbox_public";
const image = readFileSync("document.jpg").toString("base64");

const response = await fetch("https://api.doc.cheap/v1/scans", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${key}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ image }),
});

console.log(response.status, key.slice(0, 16));

Python

import base64
import json
import os
import urllib.request

key = os.environ.get("DOC_CHEAP_API_KEY", "sk_sandbox_public")

with open("document.jpg", "rb") as file:
    image = base64.b64encode(file.read()).decode()

request = urllib.request.Request(
    "https://api.doc.cheap/v1/scans",
    data=json.dumps({"image": image}).encode(),
    headers={
        "Authorization": f"Bearer {key}",
        "Content-Type": "application/json",
    },
)

with urllib.request.urlopen(request) as response:
    status = response.status

print(status, key[:16])

What a revoked key answers

A revoked key stops authenticating immediately, and it is refused the way an invented key is: 401 unauthorized. A caller cannot tell a revoked key from one that never existed, which is deliberate.

curl

curl -X POST https://api.doc.cheap/v1/scans \
  -H "Authorization: Bearer sk_live_revoked_example" \
  -H "Content-Type: application/json" \
  -d '{"image": "aGVsbG8="}'

JavaScript

const response = await fetch("https://api.doc.cheap/v1/scans", {
  method: "POST",
  headers: {
    Authorization: "Bearer sk_live_revoked_example",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ image: "aGVsbG8=" }),
});

const body = await response.json();
console.log(body.error.code, body.error.docs_url);

Python

import json
import urllib.error
import urllib.request

request = urllib.request.Request(
    "https://api.doc.cheap/v1/scans",
    data=json.dumps({"image": "aGVsbG8="}).encode(),
    headers={
        "Authorization": "Bearer sk_live_revoked_example",
        "Content-Type": "application/json",
    },
)

try:
    with urllib.request.urlopen(request) as response:
        status = response.status
        body = json.load(response)
except urllib.error.HTTPError as error:
    status = error.code
    body = json.load(error)

print(status, body["error"]["code"])

A revoked key also drops out of the key list. Withdrawal is permanent, so keep your own note of which prefix served which deployment.

Keep an inventory you can act on

A rotation is fast when you already know which key is where. Keep three things for every live key you issue.

Record Why it is needed
The prefix The only identifier that survives after the secret is shown
Where the secret is configured What has to be rolled before the old key goes
Who issued it, and when What tells a stale key from a current one

Name the key for its destination when you create it. A list of keys named for their services is an inventory; a list of keys named "key 3" is not.

Rotate one destination at a time. A rotation that touches two services at once turns a failure into a question about which one it came from.

Set an expiry when the key is temporary

A key can be created with an end of validity. Past it the key is refused like an unknown one, with no action needed from you.

Use it for a contractor, a migration or a trial integration. A key that expires on a date you chose is one you cannot forget to withdraw.

Next