---
title: "Add Self-Serve API Keys to Your Own App"
description: "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."
canonicalUrl: "https://zuplo.com/blog/2026/05/06/add-self-serve-api-keys-to-your-own-app"
pageType: "blog"
date: "2026-05-06"
authors: "martyn"
tags: "API Key Authentication, Tutorial"
image: "https://zuplo.com/og?text=Add%20Self-Serve%20API%20Keys%20to%20Your%20Own%20App"
---
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](https://zuplo.com/docs/api/api-keys-consumers) 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.

<CalloutAudience
  variant="useIf"
  items={[
    `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`,
  ]}
/>

<CalloutVideo
  title="Self-serve API keys: end-to-end walkthrough"
  description="A walkthrough of the reference implementation: the four layers, the settings page, and the Bucket API calls behind each operation."
  videoUrl="https://youtu.be/M1s-7WDZ70s"
  thumbnailUrl="https://img.youtube.com/vi/M1s-7WDZ70s/maxresdefault.jpg"
/>

## 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.

| #   | Operation                       | Your route                       | Zuplo call                                                             |
| --- | ------------------------------- | -------------------------------- | ---------------------------------------------------------------------- |
| 1   | Enable API access (mints first) | `POST /api/api-keys/enable`      | `POST /v1/.../consumers?with-api-key=true`                             |
| 2   | List the user's keys (masked)   | `GET /api/api-keys`              | `GET /v1/.../consumers/{name}?include-api-keys=true&key-format=masked` |
| 3   | Create another key              | `POST /api/api-keys`             | `POST /v1/.../consumers/{name}/keys`                                   |
| 4   | Revoke one key                  | `DELETE /api/api-keys/:keyId`    | `DELETE /v1/.../consumers/{name}/keys/{keyId}`                         |
| 5   | Rotate one key with grace       | `POST /api/api-keys/:keyId/roll` | `POST /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](https://zuplo.com/docs/api/api-keys-consumers) and
[keys](https://zuplo.com/docs/api/api-keys-keys) REST docs.

<CalloutDoc
  title="Using the Zuplo API Key API"
  description="Endpoint reference for the Bucket API: consumers, keys, query parameters, and the developer API key from the Portal."
  href="https://zuplo.com/docs/articles/api-key-api"
  icon="book"
/>

## 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:

```json
{
  "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:

```json
{
  "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.

<CalloutTip variant="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.
</CalloutTip>

## 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)](/blog-images/add-self-serve-api-keys-to-your-own-app/diagram-1.png)

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.

```ts
// 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:

```
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:

```ts
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](/blog-images/add-self-serve-api-keys-to-your-own-app/diagram-2.png)

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.

```ts
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](/blog-images/add-self-serve-api-keys-to-your-own-app/api-keys-page.png)

- **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.

<CalloutDoc
  title="API Key Management Demo"
  description="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."
  href="https://github.com/zuplo-samples/api-key-management-demo"
  icon="code"
/>

## 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.