DEV Community

Cover image for API Key Management for a Public SaaS API
Iurii Rogulia
Iurii Rogulia

Posted on • Originally published at iurii.rogulia.fi

API Key Management for a Public SaaS API

The moment your API is public and key-authenticated, you own a small identity system. Every request arrives with a secret. You have to prove it's real, decide what it's allowed to do, let the customer replace it when it leaks, and answer "which key made this call?" months later. None of that is exotic, but the naive version — a random string stored in a key column and compared with WHERE key = ? — quietly gets several of those things wrong.

I built this layer for vatnode.dev, a public EU VAT validation API where customers self-serve: they sign up, mint a key, and start calling GET /v1/vat/:vatId without ever talking to me. That means the key system has to be right on its own — there's no support step to catch a mistake. What vatnode ships today is the core: hashed storage, prefix + last-4 display, fail-closed validation, and binary revocation. Scoping, scheduled rotation, and per-key usage counts are the extensions I reach for when an API grows into needing them — I'll flag each one as I go, so it's clear what's in the shipped schema versus what's a design I'd add on top of it.

The Data Model First

Everything downstream depends on what you store. Here's the vatnode schema, in Drizzle:

// apps/api/src/db/schema.ts
export const apiKeys = pgTable(
  "api_keys",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    label: varchar("label", { length: 100 }).notNull(),
    keyHash: text("key_hash").notNull(),
    keyPrefix: varchar("key_prefix", { length: 20 }).notNull(),
    keyHint: varchar("key_hint", { length: 8 }).notNull(),
    environment: varchar("environment", { length: 10 }).default("live").notNull(),
    lastUsedAt: timestamp("last_used_at"),
    createdAt: timestamp("created_at").defaultNow().notNull(),
    revokedAt: timestamp("revoked_at"),
  },
  (table) => [
    index("api_keys_user_id_idx").on(table.userId),
    uniqueIndex("api_keys_key_hash_idx").on(table.keyHash),
  ]
);
Enter fullscreen mode Exit fullscreen mode

Two columns do the heavy lifting and are easy to overlook:

  • keyHash — never the plaintext key. More on this below.
  • keyPrefix + keyHint — the two fragments you're allowed to show a human after issuance. The prefix (vat_live_) tells you the environment at a glance; the hint is the last four characters, enough for a customer to recognize a key in a list without it being a usable secret.

The unique index on keyHash isn't decoration. It's what makes validation a single indexed lookup instead of a table scan, and it structurally guarantees no two keys collide to the same hash.

Never Store the Plaintext Key

This is the one rule you cannot compromise. An API key is a bearer credential — whoever holds it is the account, for whatever the key is scoped to. If your database is dumped, every plaintext key in it is immediately usable against your production API. You want that dump to be worthless.

So you store a hash and compare hashes. On issuance:

// apps/api/src/services/apiKeys.ts
import { randomBytes, createHash } from "node:crypto";

export async function generateApiKey(userId: string, label: string, env: "live" | "test") {
  const randomPart = randomBytes(32).toString("base64url");
  const prefix = env === "live" ? "vat_live_" : "vat_test_";
  const fullKey = `${prefix}${randomPart}`;

  const keyHash = createHash("sha256").update(fullKey).digest("hex");
  const keyHint = randomPart.slice(-4);

  await db.insert(apiKeys).values({
    userId,
    label,
    keyHash,
    keyPrefix: prefix,
    keyHint,
    environment: env,
  });

  return { key: fullKey, hint: keyHint }; // fullKey is shown here and never again
}
Enter fullscreen mode Exit fullscreen mode

Two decisions in that function deserve a note.

Why SHA-256 and not bcrypt. For user passwords you want a slow, salted hash — bcrypt, scrypt, argon2 — precisely because passwords are low-entropy and guessable. An API key is different: it's 32 bytes from a CSPRNG, base64url-encoded. That's 256 bits of entropy. There is nothing to brute-force and no dictionary to try, so a slow hash buys you nothing but latency on every single API request. A fast cryptographic hash is the right tool here. The property you need — "the stored value can't be reversed into the key" — is exactly what SHA-256 gives you, and it's fast enough to run on the hot path.

randomBytes, not Math.random(). The key's entire security is its unguessability. Math.random() is not a CSPRNG and must never generate a credential. Use crypto.randomBytes.

The return value is the only time the full key exists in your system as plaintext. Show it once, tell the user it won't be shown again, and let it fall out of memory. Everything after this point works with the hash.

slug="api-integrations"
text="Building a public API and want the key layer done right from the start — hashed storage, scopes, rotation, and usage tracking? This is what I build."
/>

Validation on the Hot Path

Every authenticated request runs this. It has to be fast and it has to fail closed.

// apps/api/src/services/apiKeys.ts
export async function validateApiKey(key: string): Promise<ValidatedApiKey | null> {
  const keyHash = createHash("sha256").update(key).digest("hex");

  const [apiKey] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash));

  if (!apiKey || apiKey.revokedAt) return null;

  // Fire-and-forget: don't block the request on a usage write
  db.update(apiKeys)
    .set({ lastUsedAt: new Date() })
    .where(eq(apiKeys.id, apiKey.id))
    .catch(() => {});

  return {
    id: apiKey.id,
    userId: apiKey.userId,
    environment: apiKey.environment,
  };
}
Enter fullscreen mode Exit fullscreen mode

Three properties matter here:

  • Hash-then-lookup. You hash the incoming key with the same algorithm and query by keyHash. Because of the unique index, that's an O(1) indexed read — the plaintext key never touches the query.
  • Revoked keys fail closed. revokedAt being set is treated identically to the key not existing. A revoked key returns null, which upstream turns into 401.
  • The lastUsedAt write is fire-and-forget. Updating a timestamp on every request would put a database write on the critical path of a read API. Detaching it with .catch(() => {}) means usage tracking never adds latency and never fails a request. If one update is lost, lastUsedAt is off by a few seconds — an acceptable trade for keeping the hot path clean.

One property worth noting: your application code never string-compares the plaintext secret. You hash the incoming key and hand a hash to the query, so the naive if (storedKey === incomingKey) — the classic place a timing side-channel leaks a secret byte by byte — simply isn't in the path. That's the honest reason this shape is safe. What actually makes the key unguessable is upstream of all this: 256 bits of CSPRNG entropy, which no timing signal on a lookup would meaningfully help an attacker recover. I wouldn't lean on "the DB index is constant-time" as a security guarantee — treat the entropy and the absence of a secret comparison as the real defenses.

Prefix and Last-4: What You're Allowed to Show

Once a key is hashed, you have a UX problem: the customer has three live keys and needs to tell them apart in a dashboard. You can't show the key — you don't have it anymore, and you wouldn't want to render it if you did.

This is what keyPrefix and keyHint are for. The dashboard renders something like:

vat_live_••••••••••••••••••••••••••••3f9a   "Production"    last used 2 hours ago
vat_test_••••••••••••••••••••••••••••8c21   "Local dev"     last used 6 days ago
Enter fullscreen mode Exit fullscreen mode

The prefix communicates two things instantly — that it's a vatnode key (useful when a customer has secrets from a dozen services in a .env) and which environment it targets. Splitting vat_live_ from vat_test_ at the key level, not just as a flag, means a test key is structurally incapable of hitting live data: the environment travels inside the credential and is resolved at validation time. A leaked test key is a non-event.

The hint — the last four characters of the random part — is the piece a human recognizes. Four characters isn't enough to reconstruct the secret but is enough for "yes, that's the one in the CI pipeline." This is the same pattern Stripe and GitHub use, and for the same reason: it's the maximum information you can safely surface about a secret you've thrown away.

Scoping: Not Every Key Should Do Everything

A single account often needs keys with different reach. A read-only key for a reporting dashboard shouldn't be able to create webhook subscriptions. A key embedded in a widget shouldn't have the same power as one on a trusted backend. Scopes let one account issue keys of different strength without minting a new account per use case.

The model is small: attach a set of permission strings to the key and check them at the route.

// add to the api_keys schema
scopes: text("scopes").array().notNull().default(sql`ARRAY['vat:read']::text[]`),
Enter fullscreen mode Exit fullscreen mode
// apps/api/src/middleware/requireScope.ts
import type { MiddlewareHandler } from "hono";

export function requireScope(scope: string): MiddlewareHandler {
  return async (c, next) => {
    const key = c.get("apiKey"); // set by the auth middleware
    if (!key.scopes.includes(scope)) {
      return c.json({ error: "insufficient_scope", required: scope }, 403);
    }
    await next();
  };
}
Enter fullscreen mode Exit fullscreen mode
// wiring it up
app.get("/v1/vat/:vatId", requireScope("vat:read"), handleValidate);
app.post("/v1/monitors", requireScope("monitors:write"), handleCreateMonitor);
Enter fullscreen mode Exit fullscreen mode

Keep the scope vocabulary deliberately small — vat:read, monitors:read, monitors:write — and resource-oriented. The failure mode of scopes is over-design: forty granular permissions nobody can reason about, so everyone issues a key with all of them and the mechanism becomes theater. Two or three scopes that map to real capabilities beat a taxonomy.

Two rules that keep scopes honest:

  • Default to least privilege. A key created with no explicit scope gets read-only. Elevated capability should be a choice the customer made, visible in the dashboard, not the default.
  • A key can never grant more than its owner has. Scope-check against the key, but the key's scopes are a subset of what the account is entitled to. A free-plan account can't mint a key with an enterprise-only scope.

Rotation Without Downtime

Keys leak. They end up in a committed .env, a screenshot, a log line, a CI variable that got exposed. When that happens the customer needs to replace the key without a window where their integration is down. If rotation means "delete the old key, create a new one, go update production," you've forced an outage and people will avoid rotating — which is the opposite of what you want.

The fix is to let both keys be valid at once, briefly. Because every key is an independent row validated on its own hash, this falls out of the model for free:

// extension — not in the shipped vatnode service
export async function rotateApiKey(oldKeyId: string, userId: string) {
  const [old] = await db
    .select()
    .from(apiKeys)
    .where(and(eq(apiKeys.id, oldKeyId), eq(apiKeys.userId, userId)));

  if (!old || old.revokedAt) throw new Error("Key not found or already revoked");

  // 1. Issue a replacement carrying the same label, env, and scopes
  const fresh = await generateApiKey(userId, old.label, old.environment as "live" | "test");

  // 2. Grace period: old key stays valid so the customer can deploy the new one
  await db
    .update(apiKeys)
    .set({ revokedAt: sql`now() + interval '24 hours'` })
    .where(eq(apiKeys.id, oldKeyId));

  return fresh;
}
Enter fullscreen mode Exit fullscreen mode

The flow the customer experiences:

  1. They click "Rotate." A new key appears, shown once. The old key keeps working.
  2. They deploy the new key to production at their own pace.
  3. After the grace window, the old key's revokedAt is in the past, so validateApiKey starts rejecting it.

One adjustment this needs on the read side: validateApiKey above treats any non-null revokedAt as revoked, which is correct for immediate revocation but wrong for a scheduled one. For scheduled rotation, the check becomes "revoked and the revocation time has passed":

// extension — vatnode ships the binary check above; this is the graced variant
if (!apiKey || (apiKey.revokedAt && apiKey.revokedAt <= new Date())) return null;
Enter fullscreen mode Exit fullscreen mode

That single comparison gives you both behaviors: pass a past timestamp (or now()) for an immediate kill, or a future one for a grace period. Compromised-key revocation should of course be immediate — set revokedAt to now() and the key is dead on the next request. The grace period is for planned rotation, not for putting out fires.

Per-Key Usage Tracking

lastUsedAt answers "is this key alive?" — useful for showing a customer they've got a stale key sitting in an old project. But for billing, quota enforcement, and abuse detection you need counts, attributed to the specific key, not just the account.

Do not do this with a count = count + 1 on the api_keys row per request. That serializes every request for a key behind a row lock and turns a hot key into a contention point. Usage is high-write, append-heavy, and time-bucketed — treat it that way. Two layers work well together:

  • Redis counters for live quota checks. An incrementing counter per key per window is cheap and atomic, and it's what a quota check reads on the request path. (The sliding-window mechanics behind this are a topic on their own — I covered the algorithm in Redis rate limiting.)
  • A durable usage log for billing and analytics. Append one row per call — or per batch, flushed periodically — keyed by apiKeyId, so you can answer "how many live calls did key X make in July?" long after the Redis window rolled over.
// extension — not in the shipped vatnode schema
export const apiKeyUsage = pgTable(
  "api_key_usage",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    apiKeyId: uuid("api_key_id")
      .notNull()
      .references(() => apiKeys.id, { onDelete: "cascade" }),
    day: date("day").notNull(),
    count: integer("count").notNull().default(0),
  },
  (table) => [uniqueIndex("api_key_usage_key_day_idx").on(table.apiKeyId, table.day)]
);
Enter fullscreen mode Exit fullscreen mode

Rolling usage up per key per day — rather than one row per request — keeps the table small enough to query directly for a monthly invoice, while the onDelete: "cascade" means deleting a key takes its history with it. The daily grain is a deliberate trade: fine enough for billing and trend charts, coarse enough that a busy key produces one row a day, not millions.

Attributing usage to the key rather than the account is what makes the data actionable. When one key suddenly does 50× its normal volume, that's the signal a credential leaked — and because usage is per-key, you can revoke exactly that key without touching the customer's other integrations.

The Whole Lifecycle in One View

Put together, an API key moves through a fixed set of states. The first block is what vatnode ships today, each stage backed by a specific column; the second is the design I extend to when an API grows into needing it:

Stage Mechanism In vatnode
Issue CSPRNG random part + env prefix, shown once Shipped
Store SHA-256 hash + prefix + last-4 hint, plaintext dropped Shipped
Display keyPrefix + keyHint, never the secret Shipped
Validate Hash-and-lookup on a unique index, fail closed Shipped
Revoke revokedAt = now, dead on the next request Shipped
Scope Permission strings checked per route, least-privilege Extension
Rotate New key + graced revokedAt on the old one Extension
Track Redis for live quota, daily rollup for billing Extension

Where this model runs out of road is worth naming. It's built for server-to-server keys held by trusted backends. It is not a substitute for user auth in a browser — a key that has to live in client-side code will leak, and no amount of hashing at rest changes that; that's a job for short-lived tokens and a different design (see JWT vs sessions vs OAuth). And if you're in a compliance regime that requires an immutable audit trail of who did what with which key, the fire-and-forget lastUsedAt write is too lossy — you'd move usage recording onto a durable queue and accept the latency. Know which system you're building before you copy the pattern.


Key management is one of those layers that's invisible when it's right and a security incident when it's wrong. The point of hashing at rest, prefixed display, scoped permissions, and graced rotation is that a leaked key becomes a contained, recoverable event instead of an account takeover.

I built this end-to-end for vatnode.dev, and it's the same layer I put into every public API I ship. If you're building an API that other people will authenticate against and want the credential layer right from the start — get in touch. I take on API and integration work and longer engagements.

Top comments (0)