Skip to content
academia.sh

Lesson 10 / 23

API Keys

The entropy of the long-lived credential issued for machine callers, the operational value of the prefix and trailing characters, storing it as a hash, rotating it with a grace window, and limiting it with scope.

Contents

In every flow up to this point, there was a human on the other end: someone entering a password, reading the consent screen, providing the second factor. But some of the callers to the library service are not human. A batch job running overnight, the neighboring organization’s catalog syncer, a dashboard — none of these has a password, and none can read a consent screen.

The credential used for these callers is called an API key. Its structure is plain: a long, random string. Its plainness is both its strength and its weakness — it is easy to carry, and easy to leak.

What the Key Proves

The API key proves who you are, not what you can do. This distinction is the machine-valid form of the distinction in the course’s first lesson: the key is verified, and authorization is limited separately.

What sets the key apart from a token is its lifetime. A token lives briefly and is renewed; a key stays the same for months and sits in configuration files, continuous integration environments, container images. This is why two questions stand out in a key’s design: how much damage does it do when it leaks, and how quickly can it be replaced?

The account below runs the key’s entire life cycle, from issuance to rotation.

cat > key.mjs <<'EOF'
// key.mjs — API key issuance, storage, verification, and rotation
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

// 1. Issuance: prefix + random body. The prefix keeps the key's owner service readable.
const PREFIX = "polar_";
function generateKey(bytes) {
  const body = randomBytes(bytes).toString("base64url");
  return PREFIX + body;
}
const entropy = (bytes) => bytes * 8;
console.log("body bytes  entropy (bits)  example length  brute-force attempts needed (2^n)");
for (const b of [8, 16, 24, 32]) {
  console.log(`${String(b).padStart(10)} ${String(entropy(b)).padStart(14)} ${String(generateKey(b).length).padStart(14)}  2^${entropy(b) - 1}`);
}

// 2. Storage: not the key itself but its hash is kept; the prefix and last four characters are searchable.
const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE api_key (
  id TEXT PRIMARY KEY, owner TEXT NOT NULL, hash TEXT NOT NULL,
  tail TEXT NOT NULL, status TEXT NOT NULL, valid_until INTEGER)`);
const hash = (a) => createHash("sha256").update(a).digest("hex");
function save(id, owner, key, validUntil = null) {
  db.prepare("INSERT INTO api_key VALUES (?,?,?,?,?,?)")
    .run(id, owner, hash(key), key.slice(-4), "active", validUntil);
}
const old = generateKey(24);
save("AK-1", "catalog-syncer", old);
// The hash and tail differ on every run; only their lengths are printed here.
console.log("\nstored record (the key itself is absent):");
for (const s of db.prepare("SELECT id, owner, length(hash) AS hash_length, length(tail) AS tail_length, status FROM api_key").all())
  console.log("  " + JSON.stringify(s));

// 3. Verification: constant-time comparison; the reason for a mismatch is not stated openly.
const TIME = 1_770_000_000;
function verify(presented, time) {
  const computedHash = hash(presented);
  const row = db.prepare("SELECT * FROM api_key WHERE tail = ?").all(presented.slice(-4))
    .find((s) => {
      const a = Buffer.from(s.hash, "hex"), b = Buffer.from(computedHash, "hex");
      return a.length === b.length && timingSafeEqual(a, b);
    });
  if (!row) return "reject: invalid key";
  if (row.status !== "active") return `reject: key ${row.status}`;
  if (row.valid_until !== null && time >= row.valid_until) return "reject: expired";
  return `accept (${row.owner})`;
}
console.log("\nverification attempts");
console.log("  valid key              -> " + verify(old, TIME));
console.log("  random different key   -> " + verify(generateKey(24), TIME));

// 4. Rotation: a new key is issued, the old one stays valid through a grace window.
const fresh = generateKey(24);
save("AK-2", "catalog-syncer", fresh);
db.prepare("UPDATE api_key SET valid_until = ? WHERE id = 'AK-1'").run(TIME + 3600);
const stages = [
  ["before rotation", TIME - 10],
  ["in the grace window", TIME + 60],
  ["after the window closes", TIME + 7200],
];
console.log("\nstage                      old key                 new key");
for (const [name, time] of stages)
  console.log(`  ${name.padEnd(26)} ${verify(old, time).padEnd(23)} ${verify(fresh, time)}`);

// 5. After a leak: revocation takes effect immediately, regardless of duration.
db.prepare("UPDATE api_key SET status = 'revoked' WHERE id = 'AK-2'").run();
console.log("\nafter revocation, new key -> " + verify(fresh, TIME + 60));

// 6. Scope and source limitation: the key alone is not authorization.
const LIMITS = { "AK-1": { scopes: ["catalog:read"], networkBlocks: ["10.0.0.0/8"] } };
console.log("\nper-key limit: " + JSON.stringify(LIMITS["AK-1"]));
console.log("  the key is verified; authorization is further limited by scope and source network.");
EOF
node key.mjs
body bytes  entropy (bits)  example length  brute-force attempts needed (2^n)
         8             64             17  2^63
        16            128             28  2^127
        24            192             38  2^191
        32            256             49  2^255

stored record (the key itself is absent):
  {"id":"AK-1","owner":"catalog-syncer","hash_length":64,"tail_length":4,"status":"active"}

verification attempts
  valid key              -> accept (catalog-syncer)
  random different key   -> reject: invalid key

stage                      old key                 new key
  before rotation            accept (catalog-syncer) accept (catalog-syncer)
  in the grace window        accept (catalog-syncer) accept (catalog-syncer)
  after the window closes    reject: expired         accept (catalog-syncer)

after revocation, new key -> reject: key revoked

per-key limit: {"scopes":["catalog:read"],"networkBlocks":["10.0.0.0/8"]}
  the key is verified; authorization is further limited by scope and source network.

Issuance: Entropy and Prefix

The output’s first table shows the body length’s conversion into entropy. A sixteen-byte body carries a hundred and twenty-eight bits of entropy; finding it by brute force is practically impossible. Longer keys add no extra security — they only bloat configuration files.

The prefix, on the other hand, is not for security but for operations. When a key leaks into a log or into source code, the prefix states which service it belongs to at a glance; leak scanners work off this prefix too. For the same reason, the key’s last few characters are kept in the record: this is the only way the user can answer the question “which key should I revoke?”

Storage: The Key Itself Is Not Kept

The second section shows the stored record, and the record has no key itself in it. What is kept is its hash, its last four characters for lookup, and its status.

The consequence of this is that the key can be shown only once: it is given to the user at the moment it is issued, and cannot be read again after that. A lost key is not recovered — a new one is issued. This restriction looks like a loss of convenience, but it prevents keys from being directly usable in a database leak.

There is a difference from storing a password: an API key has high entropy and is random, and is not exposed to a dictionary attack. This is why a slow hash is not needed; a fast hash is sufficient, and preferred precisely because it is verified on every request. Verification still needs to be done with a constant-time comparison.

Rotation: The Grace Window

The fourth section shows rotation stage by stage. A new key is issued and the old key is given an expiration date; during the grace window, both are valid. This gives the calling side time to update its configuration.

Without a window, rotation is an outage: the moment the key changes, every job running with the old configuration drops. The window’s length is chosen according to the calling side’s deployment frequency, and at the end of the window it is verified by measurement that the old key is really no longer in use — the last-used time is kept in the record.

The fifth section shows the exception: in the case of a leak, there is no window. Revocation takes effect immediately and runs before the duration check; an outage is preferable to a leaked key staying open.

The Key Alone Is Not Authorization

The last section lists the per-key limits. A verified key states who the caller is; what they can do is limited by scope, and where they can call from is limited by a source network restriction. The read-only key of a syncer must not be able to reach write endpoints — this is the rule from the Scopes and Permissions lesson applied to machine callers.

Where the key is stored is also part of the design. A key written into source code, a container image, or version control is open to everyone who can access that repository. The right place is a secret store read at runtime; the boundary from the Separating Secrets lesson applies here too.

Summary

  • An API key is a long-lived credential used for machine callers with no consent screen; it proves who you are, and scope determines authorization.
  • The prefix and trailing characters are for operations: leak scanning and the “which key should I revoke?” question work off them.
  • The record keeps the key’s hash, not the key itself; the key is shown only at the moment it is issued. Because it has high entropy, a fast hash is sufficient, and the comparison is constant-time.
  • In rotation, the old and new keys are valid together during a grace window; in a leak there is no window, revocation is effective immediately.
  • The key lives in a secret store; it is not written into source code, a container image, or version control.

Next Step

The key is the machine caller’s only proof: whoever holds it is the caller. For human users, relying on a single proof is not enough — once the password is compromised, the whole account opens up. The course’s last lesson on this topic addresses a second layer of proof: what kinds of factors exist, how a time-based code is verified, how clock skew is accommodated, and why recovery codes are single-use.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close