Zuplo
API Key Authentication

Add Self-Serve API Keys to Your Own App

Martyn DaviesMartyn Davies
May 6, 2026
10 min read

Hand-rolling API key management means hashing, rotation, leak detection, and a key store you can trust. The Zuplo API gives you all of it from any stack, in five HTTP calls.

Sooner or later your API needs a settings page where users mint, rotate, and revoke their own keys. The list of things to get right is long: hash on write, never store plaintext, surface the value once, mask it forever after, propagate revocations everywhere the key is cached, support rotation without breaking whoever’s using it, and detect leaked keys before they cost you. Each one is a small project; together they are a real one.

Zuplo already does all of that for the keys it issues. Buckets are the top-level container in Zuplo’s managed key system (one per project or environment), and the Bucket API exposes the same primitives over HTTP.

Wire your settings page directly into the bucket your gateway already validates against, and skip the underlying engineering. The five operations fit on one screen, and the contract is the same no matter what your app is built in.

Use this approach if you're:
  • You want a self-serve keys page without building hashing, rotation, or leak detection
  • You're tired of fielding "I lost my key" support tickets
  • You run Zuplo as your gateway and want your app to mint keys against the same bucket
Self-serve API keys: end-to-end walkthrough
Video Tutorial

Self-serve API keys: end-to-end walkthrough

A walkthrough of the reference implementation: the four layers, the settings page, and the Bucket API calls behind each operation.

What You’re Not Building

You don’t build a key store. Plaintext keys never touch your database. Zuplo returns the plaintext value exactly once at creation, and every read after that returns a masked form like zpka_abc1...4f9c. That masked value is what your UI displays everywhere except the one-time reveal.

You don’t build rotation. Rotating a key is a create-then-expire pair against the Bucket API; the gateway validates both during the grace window.

You don’t build leak detection. Every key Zuplo issues carries a zpka_ prefix that GitHub’s secret scanners recognise, because Zuplo is a GitHub secret scanning partner. Commits to a scanned repo trigger an alert.

You don’t build edge revocation. When you delete a key, Zuplo propagates the revocation to its edge locations within seconds.

What’s left is the part of your app that knows your users: which ones have enabled API access, what their consumer name is in Zuplo, and the settings page that drives the five operations. That part is small.

One App User, One Zuplo Consumer

One app user maps to one Zuplo consumer. The consumer is Zuplo’s identity object: a name, optional metadata, optional tags, and a set of API keys. Names match ^[a-z0-9-]{1,128}$, so a safe default is user-<lowercased-uuid> derived from your user id.

The consumer name is the only thing your database has to remember. A dedicated table with two columns (user_id, consumer_name) is enough. Don’t stuff this into your existing users table. A dedicated table means removing the feature is one drop.

On create, set metadata: { appUserId, email } and tags: { appUserId }. The metadata travels with the consumer and is available to your gateway at request time, so your origin can know which app user a request belongs to without your backend doing any user lookup.

How the gateway forwards that metadata to your origin is a gateway-side decision; the management module just makes sure it’s there.

The Five Operations

Self-serve key management collapses to five operations, each a thin wrapper over a Zuplo API call. Build these and you have the full feature. The full Zuplo path is /v1/accounts/{ACCOUNT_NAME}/key-buckets/{BUCKET_NAME}/consumers/...; the table abbreviates the prefix with /v1/.../consumers for readability.

#OperationYour routeZuplo call
1Enable API access (mints first)POST /api/api-keys/enablePOST /v1/.../consumers?with-api-key=true
2List the user’s keys (masked)GET /api/api-keysGET /v1/.../consumers/{name}?include-api-keys=true&key-format=masked
3Create another keyPOST /api/api-keysPOST /v1/.../consumers/{name}/keys
4Revoke one keyDELETE /api/api-keys/:keyIdDELETE /v1/.../consumers/{name}/keys/{keyId}
5Rotate one key with gracePOST /api/api-keys/:keyId/rollPOST /v1/.../consumers/{name}/keys then PATCH .../keys/{keyId}

Each Zuplo call is authenticated with the developer API key from the Zuplo Portal (Settings → API Keys), sent as Authorization: Bearer ${ZUPLO_API_KEY}. This is a server-side credential for your account, not one of your users’ keys.

Send Content-Type: application/json on requests with a body. Treat any non-2xx response as an error and bubble status plus body to your service layer.

Full endpoint reference for the operations on consumers and keys lives in the consumers and keys REST docs.

Using the Zuplo API Key API

Endpoint reference for the Bucket API: consumers, keys, query parameters, and the developer API key from the Portal.

The Plaintext Contract

This is the easy thing to get wrong. Zuplo returns the plaintext key exactly once, at creation. Every read after that returns a masked form. So:

  • Operations 1, 3, and 5 (enable, create, roll) return plaintext.
  • Operation 2 (list) returns masked values, driven by key-format=masked on the get-consumer call.
  • Your server returns the plaintext to the client immediately on creation and never persists it.
  • Your UI shows the plaintext in a one-time reveal banner with a copy action. After dismiss, the value is unrecoverable.

A GET /api/api-keys response your settings page can render directly:

JSONjson
{
  "enabled": true,
  "keys": [
    {
      "id": "key_01H...",
      "description": "CI",
      "createdOn": "2026-04-22T10:14:00.000Z",
      "expiresOn": null,
      "key": "zpka_abc1...4f9c"
    }
  ]
}

A POST /api/api-keys response, returned exactly once:

JSONjson
{
  "key": {
    "id": "key_01J...",
    "description": "Local laptop",
    "createdOn": "2026-04-28T12:00:00.000Z",
    "expiresOn": null,
    "key": "zpka_live_8c2c7d...full plaintext..."
  }
}

Same shape, two different lives. The list response feeds the table; the create response feeds the reveal banner, once.

Common mistake:

Storing the plaintext “just in case the user loses it.” The first time you ship this, you’ll get a support ticket from someone who closed the reveal banner before copying, and the temptation is to add a “show me my key again” path. That undoes the entire security model. The right answer to every “I lost my key” ticket is rotation, not retrieval.

Build It in Four Layers

The structure is the same for any stack. Pick a backend framework, pick a database, drop the layers in.

Four-layer architecture: HTTP routes call the service layer, which composes calls to the storage adapter (your database) and the Zuplo HTTP client (the Zuplo Bucket API)

  1. Zuplo HTTP client. A typed wrapper around https://dev.zuplo.com. It knows nothing about your app, users, or database. Just request shapes, response shapes, and an error type that exposes status and body.
  2. Storage adapter. An interface with two methods, getConsumerName(userId) and setConsumerName(userId, consumerName). Implement it once against your database, one row per user that has enabled API access, in a dedicated zuplo_consumers table.
  3. Service layer. One function per user-facing operation: isEnabled, enableApiAccess, listKeys, createKey, revokeKey, rollKey. Each composes a storage lookup with one or two Zuplo calls. Tests mock the Zuplo client and storage and run in isolation.
  4. HTTP routes. Thin handlers that call the service. No business logic. They expect an authenticated user object on the request from your existing auth middleware.

The shape is identical whether your backend is FastAPI, Rails, Express, Spring, or Go. Only the language changes.

Here’s the Zuplo client function for issuing a new key, written in TypeScript. It runs server-side, called by your POST /api/api-keys handler after authenticating the user. Translate it to your language of choice; the URL, headers, and body are what matter.

TypeScriptts
// All three are required at boot.
const ACCOUNT = process.env.ZUPLO_ACCOUNT_NAME!;
const BUCKET = process.env.ZUPLO_BUCKET_NAME!;
const API_KEY = process.env.ZUPLO_API_KEY!;

type Key = {
  id: string;
  description: string | null;
  createdOn: string;
  expiresOn: string | null;
  key: string;
};

async function createKey(
  consumer: string,
  description: string | null,
): Promise<Key> {
  const url = `https://dev.zuplo.com/v1/accounts/${ACCOUNT}/key-buckets/${BUCKET}/consumers/${consumer}/keys`;

  const res = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ description }),
  });

  if (!res.ok) {
    throw new ZuploError(res.status, await res.text());
  }
  return (await res.json()) as Key;
}

The response includes the plaintext key, which is your only chance to read it. Hand it straight to the UI for the one-time reveal; don’t write it to your database.

Three required environment variables, server-side only:

plaintext
ZUPLO_API_KEY=zpka_…           # Developer API key from the Zuplo Portal
ZUPLO_ACCOUNT_NAME=…           # Portal → Settings → Project Information
ZUPLO_BUCKET_NAME=…            # Portal → Settings → Project Information

Enable With Zuplo-First Ordering

Operation 1 has one rule that matters: call Zuplo first, write to your database second. Persist the consumer name before the Zuplo call succeeds and a Zuplo failure leaves you with an orphaned row pointing at a consumer that doesn’t exist.

The service function in TypeScript:

TypeScriptts
async function enableApiAccess(userId: string, email: string): Promise<Key> {
  const consumerName = `user-${userId.toLowerCase()}`;

  // 1. Zuplo first. The consumer comes back wrapping the new key in plaintext.
  const { apiKeys } = await zuploCreateConsumerWithKey({
    name: consumerName,
    metadata: { appUserId: userId, email },
    tags: { appUserId: userId },
  });

  // 2. Then the database. If this fails you reconcile on retry,
  //    rather than ending up with a row pointing at nothing.
  await storage.setConsumerName(userId, consumerName);

  return apiKeys[0];
}

The with-api-key=true query parameter mints the first key in the same round trip. The response includes the plaintext value, which you return to the UI for its one-time reveal banner.

Enable-flow sequence: the server calls Zuplo first to create the consumer with its first API key, and only writes the consumer name to the database after Zuplo returns success, so a Zuplo failure cannot leave a phantom row

If the database write fails after Zuplo succeeds, the consumer exists in Zuplo but your app doesn’t know about it.

On retry, the user clicks Enable again, the create-consumer call returns 409 Conflict, and your handler treats 409 as “consumer already exists, write the row, then mint a fresh key on the existing consumer” so the user has plaintext to copy.

The original orphaned key sits unused in the masked list, where the user can revoke it from the settings page. Zuplo-first ordering means the worst case is a spare key in the masked list, not a phantom row in your database.

Rotation With a Grace Period

Rotation is the one place where the obvious endpoint is the wrong choice. Zuplo exposes a consumer-level roll-key endpoint that rotates every key for a consumer at once and returns 204 with no plaintext. Useful for a “rotate everything for this user right now” admin action, but a poor fit for a self-serve UI where users rotate one key at a time and need the new plaintext to copy into a deploy config.

For per-key rotation, compose two calls. Mint a new key on the same consumer, then PATCH the old key to set its expiresOn to a future timestamp. Both keys validate at the gateway during the grace window; clients swap the old value for the new on their own schedule, and the old key expires automatically.

TypeScriptts
async function rollKey(
  userId: string,
  oldKeyId: string,
  expiresOn: string,
): Promise<{ newKey: Key; expiringKey: Key }> {
  const consumerName = await storage.getConsumerName(userId);

  // Mint the new key first. This is your one shot at the plaintext.
  const newKey = await zuploCreateKey(consumerName, "Rolled key");

  // Then set the old key to expire later.
  const expiringKey = await zuploPatchKey(consumerName, oldKeyId, {
    expiresOn,
  });

  return { newKey, expiringKey };
}

Pick the grace period to match the rotation: 24 to 72 hours covers most self-serve flows, longer windows suit scheduled rollouts. The UI exposes presets (24h / 72h) and submits an ISO-8601 timestamp; for an immediate kill the user reaches for Revoke (operation 4), not Roll.

The list operation returns both keys, masked, until the old one expires. The settings page shows both rows, with the old one marked “expires on…”, which is what tells the user the rotation is in flight.

The Settings Page

The UI is small. Five elements, one route in your app. The screenshot below is from a reference implementation of this exact module, built end-to-end against the Bucket API.

Reference settings page showing the masked keys table with Roll and Revoke actions, plus a one-time reveal banner at the top

  • EnableCard. Shown when enabled === false. Single button, calls operation 1.
  • RevealBanner. Appears after operations 1, 3, and 5. Shows the plaintext with copy and dismiss. Says clearly this is the only time the value will be shown. Replaces any previously-revealed key.
  • CreateKeyForm. Optional description field, calls operation 3.
  • KeysTable. Masked key, description, created, expires, plus a per-row actions menu with Roll and Revoke.
  • Confirmation dialogs. Revoke is irreversible and immediate; Roll takes a grace period. Both deserve a confirm dialog.

The component names are illustrative. The response shapes your server returns are not. As long as GET /api/api-keys returns the two states above and your create endpoints return { key: ApiKey }, the exact UI is yours.

API Key Management Demo

A working reference implementation of this exact pattern, with all four layers and the settings page wired end-to-end. Clone it, run it, and adapt to your stack.

Stack-Agnostic Is the Point

This works in any stack because the boundary is HTTP, JSON, and a bearer token. The four layers only deal in those primitives plus the user object your auth middleware already hands them. No SDK to vendor, no framework to adopt, no ORM the Zuplo client cares about.

That means three concrete things on the day you ship:

  • Portable across services. A Rails monolith and a Go service can implement the same four layers against the same Zuplo bucket and produce keys that validate identically at the gateway.
  • Portable across rewrites. Migrating frameworks doesn’t move keys, hashes, or rotation history. The keys live in Zuplo. Your database remembers a string per user.
  • Portable across teams. The frontend can be built against the response shapes above before the backend exists, mocked from a JSON fixture. The integration is four endpoints, not a framework.

What you ship on top is your settings page, your auth middleware, and a two-column table. What you don’t ship is everything underneath.