Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelogContact
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. API Key Management UI: Create, Show Once, Roll, Revoke
Auf Deutsch lesen
  1. Blog
  2. Build it: screens and apps
  3. API Key Management UI: Create, Show Once, Roll, Revoke
Build it: screens and apps
Build it: screens and apps

API Key Management UI: Create, Show Once, Roll, Revoke

SR
Serban Rusu · Founder of Wingo UI
Nov 6, 2026 · 10 min read

On this page

0%
  1. What does an API key management UI need?
  2. What should the server store for each key?
  3. How do you show the secret only once?
  4. How do you wire the API keys page to Next.js?
  5. Should rolling a key end the old secret right away?
  6. When do you need a data table instead?
  7. How does it behave on phones and for keyboard users?
  8. Which React API key manager should you start from?
  9. Next step
  10. FAQ
    1. Should users be able to see an API key again after creating it?
    2. How should I store API keys in the database?
    3. What is the difference between rolling and revoking an API key?
    4. Why do API keys have a prefix like sk_live_ or ghp_?
    5. How should an API keys page work on a phone?

TL;DR

An API key management UI lists each key by name, a masked prefix, scopes, last use and expiry, and lets the user create, rename, roll and revoke keys. The secret is shown exactly once, right after it is created or rolled, with a Copy button, and the server stores only a hash of it. In React you can start from the Wingo UI API Keys Page block and pass your own create, roll, rename and revoke handlers.

Published Nov 6, 2026

Your product has an API, so your settings need an API key management UI: a screen where a developer creates a key, copies it into an environment variable, and later rolls or revokes it without opening a support ticket. Most of what you find on the topic is either a gallery block that renders a table with a masked string or vendor docs on how keys should be stored. Few connect the two. This guide covers both halves: the screen (create with scopes and expiry, show the secret once, copy, roll, rename, revoke) and the backend contract behind it (store a hash, display a prefix), built in Next.js with the API Keys Page block.

What does an API key management UI need?

It needs five things: a scannable list, a create flow with scopes and expiry, a one-time reveal, guarded roll and revoke, and limits with warnings. Good API key UI design keeps all five, because each one prevents a specific failure:

  1. A list a human can scan. Name, a masked prefix (ak_live_3f9a••••), status, scopes, created date, last used and expiry. "Last used 3 hours ago" is what tells someone a key is safe to delete.
  2. Create with intent. A name, the scopes the key may use and an expiry. A key without a name becomes "Key 4" and nobody dares revoke it.
  3. A one-time reveal. The secret appears once, selected, with a Copy button and a warning that it will not be shown again.
  4. Roll and revoke, behind a confirmation. Both break running integrations, so both ask first, and revoke is red.
  5. Limits and warnings. A plan cap on active keys that disables Create with a reason, and an amber "Expires in 5 days" before a key silently stops working.

The API Keys Page block ships all five. Try it: open the row menu on "CI pipeline", roll it, then create a new key.

Open a row's menu to roll, rename or revoke, or press Create key: pick scopes and an expiry, then copy the secret from the one-time reveal
$ npx wingo-ui@latest add api-keys-page
ProAPI Keys Page docs

What should the server store for each key?

Store a hash of the secret and a short prefix, never the secret itself. The prefix is display-only, so users can tell keys apart. The hash is how you find the key when a request arrives. Stripe's API keys docs (opens in a new tab) (checked November 2026) say that a secret key you create in live mode is displayed one time and cannot be revealed later, and that if you lose it you rotate it. That is the model to copy.

A key format with a recognizable prefix also helps secret scanners. GitHub explained its ghp_, gho_ and related prefixes, plus a checksum, in a post from April 2021 (opens in a new tab): the underscore is not a Base64 character, so random strings rarely look like tokens, and double-clicking selects the whole token. Use letters and digits only in the body for the same reason.

ts
// server/api-keys.ts
import { createHash, randomBytes, randomInt } from "node:crypto"
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
function randomBase62(length: number) {
return Array.from({ length }, () => ALPHABET[randomInt(ALPHABET.length)]).join("")
}
export function sha256(value: string) {
return createHash("sha256").update(value).digest("hex")
}
// ak_live_3f9a_<32 random characters>; only prefix and hash are saved
export function newApiKey() {
const prefix = `ak_live_${randomBytes(2).toString("hex")}`
const secret = `${prefix}_${randomBase62(32)}`
return { prefix, secret, hash: sha256(secret) }
}

Thirty-two base62 characters carry about 190 bits of entropy, so a fast SHA-256 with a unique index is enough. A slow password hash like bcrypt protects guessable passwords; here it would only add latency to every API request. Checking a request is one indexed lookup:

ts
// server/authenticate.ts
import { db } from "@/server/db" // your data layer (Drizzle, Prisma, SQL)
import { sha256 } from "@/server/api-keys"
export async function authenticate(request: Request) {
const secret = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "")
if (!secret?.startsWith("ak_live_")) return null
const key = await db.apiKey.findUnique({ where: { hash: sha256(secret) } })
if (!key || key.revokedAt || (key.expiresAt && key.expiresAt < new Date())) return null
// write lastUsedAt at most once a minute, not on every request
if (!key.lastUsedAt || Date.now() - key.lastUsedAt.getTime() > 60_000) {
await db.apiKey.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } })
}
return key
}

Status is derived, not stored: a key with revokedAt is revoked, a key past expiresAt is expired, anything else is active. The block treats an active key whose date has passed as expired too, so the list stays honest even if your data lags.

How do you show the secret only once?

Show it in the same dialog that created it, focused and selected, and make it hard to close by accident. In the block, after you press Create key the dialog swaps the form for a read-only field holding the secret, a Copy Button inside it and a warning. Done stays disabled until you copy or tick "I saved this key somewhere safe", and outside clicks and Escape do nothing until then. The secret never goes into a toast, the URL or storage, and it leaves memory once the dialog closes.

The create dialog: name the key, pick scopes and an expiry, press Create key, then copy the secret before Done turns on
$ npx wingo-ui@latest add api-keys-page
ProAPI Keys Page docs

The same reveal runs after a roll, titled with the key's name. That covers the most common support request about keys, "I lost my key." The answer is Roll, never "we will email it to you." Whatever label your generate API key UI uses (Create key, New token, Generate), the secret should exist in the browser only while this dialog is open.

How do you wire the API keys page to Next.js?

Install the block, keep the scope and expiry lists in a shared module, and pass four Server Actions as its handlers. The install pulls in the dialog, table, copy button, alert dialog and the rest:

bash
npx wingo-ui@latest add api-keys-page

Keep the scope and expiry lists in a plain module that both the client view and the Server Actions import. The block's own defaults live in a client file, and a server module should not import values from one.

ts
// app/(app)/settings/api-keys/keys-config.ts
import type { ApiKeyExpiryOption, ApiKeyScope } from "@/components/blocks/api-keys-page-data"
export const SCOPES: ApiKeyScope[] = [
{ value: "orders:read", label: "Read orders", description: "List and fetch orders" },
{ value: "orders:write", label: "Write orders", description: "Create, update and refund orders" },
{ value: "webhooks", label: "Webhooks", description: "Manage webhook endpoints" },
]
export const EXPIRY: ApiKeyExpiryOption[] = [
{ value: "30d", label: "30 days", days: 30 },
{ value: "90d", label: "90 days", days: 90 },
{ value: "1y", label: "1 year", days: 365 },
]

Leaving out a Never option is a product decision. It forces rotation, at the cost of an expiry email you now have to send.

The Server Actions return a result object instead of throwing. The Next.js error handling guide (opens in a new tab) (checked November 2026) recommends modeling expected errors as return values, and production builds do not forward the message of a thrown server error to the client, so a thrown "You reached the limit of 10 keys" would reach the dialog as a generic error.

ts
// app/(app)/settings/api-keys/actions.ts
"use server"
import type { ApiKey } from "@/components/blocks/api-keys-page-data"
import type { ApiKeyCreateInput } from "@/components/blocks/api-keys-page"
import { db } from "@/server/db"
import { newApiKey } from "@/server/api-keys"
import { requireAdmin } from "@/server/dal"
import { EXPIRY, SCOPES } from "./keys-config"
type Result<T> = { ok: true; data: T } | { ok: false; error: string }
const DAY = 86_400_000
export async function createKey(input: ApiKeyCreateInput): Promise<Result<{ key: ApiKey; secret: string }>> {
const { user, workspaceId } = await requireAdmin()
const expiry = EXPIRY.find((option) => option.value === input.expiresIn)
const scopes = input.scopes.filter((scope) => SCOPES.some((s) => s.value === scope))
if (!input.name.trim() || !expiry || scopes.length === 0) return { ok: false, error: "Add a name, a scope and an expiry." }
// count what the block counts: not revoked and not past expiry
const active = await db.apiKey.count({
where: { workspaceId, revokedAt: null, OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] },
})
if (active >= 10) return { ok: false, error: "You reached the limit of 10 keys. Revoke one first." }
const { prefix, secret, hash } = newApiKey()
const row = await db.apiKey.create({
data: {
workspaceId,
name: input.name.trim(),
prefix,
hash,
scopes,
createdBy: user.name,
expiresAt: expiry.days === null ? null : new Date(Date.now() + expiry.days * DAY),
},
})
// a Renew replaces the expired key it came from
if (input.renews) await db.apiKey.update({ where: { id: input.renews, workspaceId }, data: { revokedAt: new Date() } })
const key: ApiKey = {
id: row.id,
name: row.name,
prefix: row.prefix,
scopes: row.scopes,
createdAt: row.createdAt.getTime(),
lastUsedAt: null,
expiresAt: row.expiresAt?.getTime() ?? null,
createdBy: row.createdBy,
status: "active",
}
return { ok: true, data: { key, secret } }
}
export async function rollKey(id: string): Promise<Result<{ secret: string }>> {
const { workspaceId } = await requireAdmin()
const { prefix, secret, hash } = newApiKey()
await db.apiKey.update({ where: { id, workspaceId, revokedAt: null }, data: { prefix, hash, lastUsedAt: null } })
return { ok: true, data: { secret } }
}
export async function revokeKey(id: string): Promise<Result<null>> {
const { workspaceId } = await requireAdmin()
await db.apiKey.update({ where: { id, workspaceId }, data: { revokedAt: new Date() } })
return { ok: true, data: null }
}
export async function renameKey(id: string, name: string): Promise<Result<null>> {
const { workspaceId } = await requireAdmin()
if (!name.trim()) return { ok: false, error: "The name cannot be empty." }
await db.apiKey.update({ where: { id, workspaceId }, data: { name: name.trim() } })
return { ok: true, data: null }
}

Every action scopes the query to the caller's workspace. The client sends an id; the server decides whether that id belongs to them. The limit check counts only keys that are neither revoked nor expired, the same rule the block uses for maxKeys, so an old expired key never blocks a new one. requireAdmin() follows the data access layer from the Next.js admin dashboard tutorial: it checks the session and, in this version, returns the signed-in user and their workspace id.

The client view turns a failed result back into an error, which the block shows inline and keeps the form:

tsx
// app/(app)/settings/api-keys/keys-view.tsx
"use client"
import { ApiKeysPage } from "@/components/blocks/api-keys-page"
import type { ApiKey } from "@/components/blocks/api-keys-page-data"
import { createKey, renameKey, revokeKey, rollKey } from "./actions"
import { EXPIRY, SCOPES } from "./keys-config"
async function unwrap<T>(result: Promise<{ ok: true; data: T } | { ok: false; error: string }>) {
const res = await result
if (!res.ok) throw new Error(res.error)
return res.data
}
export function KeysView({ keys }: { keys: ApiKey[] }) {
return (
<ApiKeysPage
defaultKeys={keys}
scopes={SCOPES}
expiryOptions={EXPIRY}
defaultExpiry="90d"
maxKeys={10}
docsHref="/docs/api"
onCreate={(input) => unwrap(createKey(input))}
onRoll={(id) => unwrap(rollKey(id))}
onRename={(id, name) => unwrap(renameKey(id, name))}
onRevoke={(id) => unwrap(revokeKey(id))}
/>
)
}

The server page.tsx loads the workspace's keys, maps them to ApiKey with timestamps as numbers, and renders <KeysView keys={keys} />. It can sit as one section of a larger React settings page.

Should rolling a key end the old secret right away?

Not always: end it at once when a key leaked, and allow a short overlap for planned API key rotation. The block's default copy says the current secret stops working right away, which is the safe choice when a key leaked. Stripe takes the other route for planned rotation: as of November 2026 its rotation docs (opens in a new tab) say both the old and new keys work for up to 7 days when you rotate in the Dashboard, so integrations can migrate without downtime.

If you want a grace window, keep the old hash in a second column with its own expiry, accept either in authenticate, and change the confirmation text so it tells the truth:

tsx
<ApiKeysPage
labels={{ rollDescription: "The current secret keeps working for 24 hours, then stops. Update your apps before then." }}
onRoll={(id) => unwrap(rollKey(id))}
/>

Revoke stays immediate either way. The roll and revoke confirmations use the Alert Dialog pattern: focus starts on Cancel, the action names what it does, a failed request shakes the card and shows the error in place.

When do you need a data table instead?

When you list keys across every workspace, for support or internal admin. The block uses a simple table because one workspace rarely has more than a dozen keys. A support view across all customers is different: thousands of keys, searched by prefix, filtered by status and creator. That is a job for the Data Table, with a badge column for status, relative columns for last used and expiry, and rowActions for revoke. Keep the one-time reveal out of that view entirely. Support staff should be able to revoke a key, not see one.

How does it behave on phones and for keyboard users?

It switches to cards and bottom sheets on phones and works fully from the keyboard. Below 768px each key becomes a card, the row menu opens as a bottom sheet, and the create dialog is a bottom sheet with full-width buttons. The cursor lands in Name when the dialog opens, Enter submits, and the secret field takes focus, selected, so Copy or Cmd+C is one step away. When the dialog closes, focus returns to the row's menu button or to Create key. Missing fields are named inline next to the field, not only in a toast. These details are what separate an API keys page design that works from one that only looks right in a screenshot.

Which React API key manager should you start from?

Start from the Wingo block if you want the whole key lifecycle wired to your handlers, and from the shadcn.io block if your scopes are a grid of resources by access level. Both are paid:

OptionCovers (as of November 2026)Price
shadcn.io "Settings API Keys" (opens in a new tab)Key table, per-resource None / Read / Write access, a request tester, cards on mobile. Reveal once is a separate block; the page lists no revoke, rotate or expiryPart of shadcn.io Pro
Wingo UI API Keys PageCreate with scopes and expiry, one-time reveal, copy, roll, rename, revoke, renew, key limit, expiry warnings, phone layoutPro, from $8 a month (pricing)

The shadcn.io block is the better fit when you want its "try this request" checker, which the Wingo block does not have. For more screens like this one, browse the Build it category or the role management UI guide, which pairs well with keys on a team settings page. Key events (created, rolled, revoked) also belong in an audit log UI that shows who changed which key and when.

Next step

Install the block with npx wingo-ui@latest add api-keys-page, point its four handlers at your Server Actions, and keep the hash on the server. The Copy Button it uses is free with a free account; the API Keys Page itself is part of Pro.

Components in this post

  • API Keys Page

    The developer settings screen for API keys: list, create with scopes and expiry, reveal the secret once, copy, roll, rename and revoke.

    Pro
  • Copy Button

    A React copy to clipboard button for IBANs, tax IDs, links and API keys: an icon button with a tooltip, a labelled button and CopyText.

    Free
  • Alert Dialog

    A React alert dialog for confirmations: an async confirm that loads, fails inline and shakes, plus an await confirm() API.

    Pro
  • Data Table

    A React data table on TanStack Table: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones.

    Pro

FAQ

Should users be able to see an API key again after creating it?

For server keys, no. Show the secret once, store only a hash, and offer Roll when someone loses it. Retrievable keys need encryption at rest and a decryption path, which is more code to protect for little gain.

How should I store API keys in the database?

Store a SHA-256 hash of the full key with a unique index, plus a short prefix for display. Look up incoming keys by hashing them. A random key with 190 bits of entropy does not need a slow password hash.

What is the difference between rolling and revoking an API key?

Revoking ends the key for good. Rolling keeps the key's name, scopes and history but issues a new secret, so the user updates one environment variable instead of recreating the key.

Why do API keys have a prefix like sk_live_ or ghp_?

A prefix tells people and secret scanners what the string is and which environment it belongs to. GitHub added prefixes and a checksum to its tokens in April 2021 so leaked tokens can be detected with few false positives.

How should an API keys page work on a phone?

Turn the table into one card per key, open the row menu as a bottom sheet, and show the create and reveal dialogs as bottom sheets with full-width buttons. The Copy button matters most there, because selecting a 45-character secret by hand on a touch screen is error-prone.

Try it in your project

API Keys Page

Pro

The developer settings screen for API keys: list, create with scopes and expiry, reveal the secret once, copy, roll, rename and revoke.

75 components free, no account needed. Pro from $8 a month.

Open API Keys Page liveSee pricing
  • API keys
  • Developer settings
  • Security
  • Next.js
  • SaaS

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Build it: screens and apps

React Settings Page: Section Nav, Save Bar, Danger Zone

Build a React settings page with a section nav on desktop, an iOS-style push list on phones, a save bar for unsaved changes and a typed danger zone.

SRSerban Rusu·Oct 16, 2026·11 min read
Build it: screens and apps

Nextjs Admin Dashboard Tutorial: 5 Screens, Phone-Ready

A nextjs admin dashboard tutorial for the App Router: app shell, KPI home, server-side data tables, settings and team pages, each one usable on a phone.

SRSerban Rusu·Oct 9, 2026·23 min read
Building UI with AI agents

Antigravity MCP Setup: The Right Config Path and Prompts

Antigravity MCP setup tested on November 6, 2026: where mcp_config.json lives, remote vs npx entries, reloading, and prompts that install real components.

SRSerban Rusu·Nov 6, 2026·10 min read
Newsletter

Get new posts by email

New posts, product news and occasional offers from Wingo UI. We count clicks to keep it relevant.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • For teams
  • Theme generator

Resources

  • Docs
  • Contact
  • Programs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Compare

  • Wingo UI vs paid kits
  • Wingo UI vs shadcn/ui
  • shadcn/ui alternatives
  • MUI alternatives
  • Tailwind Plus alternatives
  • Magic UI alternatives
  • Free components

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI