Wards & Seals

Showing a Key Without Showing It: API Key Fingerprints in Cat-AgentUI

Cat-AgentUI keeps provider API keys write-only. Today I added a short fingerprint so admins can tell keys apart, and this entry walks through how much of a key that is safe to reveal and why.

Cat-AgentUI treats provider secrets as write-only. API keys, Vertex service-account JSON, custom provider headers, and MCP environment variables and headers are encrypted at rest with SECRET_KEY and never sent back to the browser. Once you save a key, the admin page only knows that a key exists.

That rule is right, but it had a cost. When a provider has a main route and a couple of backup routes, each with its own key, the admin page showed the same ●●●●●● for all of them. If one key got rate-limited or revoked, I had no way to tell from the UI which saved key was which, short of pasting a key in again.

Today's commit (cab062f) adds a short fingerprint next to each route, in the style many vendor dashboards use:

text
sk-ant-api03-abc...wxyz

The interesting part is deciding how much of a secret you can show without weakening it.

What the fingerprint contains

The server builds the hint in server/src/secrets.ts, and the browser never sees anything else:

ts
export function apiKeyHint(encrypted: string | null | undefined): string | null {
  if (!encrypted) return null;
  let key: string;
  try { key = decryptSecret(encrypted).trim(); } catch { return null; }
  if (key.length < 16) return null;
  const tail = key.slice(-4);
  if (key.length < 24) return `...${tail}`;
  const window = key.slice(0, 14);
  const sep = Math.max(window.lastIndexOf('-'), window.lastIndexOf('_'));
  const head = sep >= 0 ? key.slice(0, sep + 4) : key.slice(0, 4);
  return `${head}...${tail}`;
}

In words:

Key length What is shown
No key, or it can't be decrypted Nothing
Shorter than 16 characters Nothing
16 to 23 characters Only the last 4 characters
24 characters or more The vendor prefix, 3 more characters, and the last 4

The vendor prefix is found by looking for the last - or _ in the first 14 characters. For sk-ant-api03-... that is the sk-ant-api03- part, which is public and identical for everyone. So the only secret material shown is 3 characters after the prefix and 4 at the end.

Why this is an acceptable leak

A fingerprint is still a disclosure, so I wanted to be clear with myself about what it gives away.

  • Seven random characters out of a long key. A modern provider key carries far more random characters than that. Revealing 7 of them removes a little search space but leaves the rest far beyond guessing.
  • Short keys get less. A short token has less entropy to spare, so the rule shows only the tail for medium-length keys and nothing at all under 16 characters. If I don't know what a value looks like, the safe default is to show nothing.
  • The prefix is not secret. Showing sk-ant-api03- tells you the vendor and key format, which the provider type already says.
  • It stays behind the admin check. The hint comes from the same admin-only provider routes as the rest of the provider config. Regular users never receive it.
  • The full key is still never echoed. The edit box keeps type="password" with an empty value. The placeholder now reads "Saved sk-ant-api03-abc...wxyz, leave blank to keep", and leaving it blank keeps the stored key.

One thing I deliberately did not do is hash the key and show a digest. A hash would leak nothing about the key itself, but it wouldn't match anything you can see in the vendor's own dashboard. The point of the feature is to line a saved key up with the one listed on the provider's site, and those sites show prefixes and last characters.

Keeping it honest with a test

Cat-AgentUI has a security regression script, scripts/security-regression.mjs, that already checks what the provider API returns about saved secrets (for example, that it reports a key exists and lists header names without their values). I added one assertion so the fingerprint format can't quietly grow:

js
// Only a short fingerprint (vendor prefix + 3 chars, last 4) comes back.
assert(configured.keyHint === 'PROVIDER_KEY_CAN...2f37', 'provider key fingerprint');

If a later change shows more of the key, for example by widening the window or returning the raw value by mistake, this test fails.

I also updated the README's security section so the documentation matches the behavior: keys are still write-only, and the only thing shown is a fingerprint like sk-ant-api03-abc...wxyz.

Takeaways

  1. Write-only secrets are the right default, but they need some way to identify which secret is stored, or people work around them by pasting keys again.
  2. Decide the disclosure budget on purpose: how many characters, and what happens for short or unusual values.
  3. Compute the fingerprint on the server, so the full value never has to reach the client.
  4. Pin the format with a regression test so it can't drift.

It's a small feature, but it's the kind of small feature where a careless version would print the whole key into the page. I'd rather write down why it shows exactly seven characters.