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:
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:
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:
// 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
- 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.
- Decide the disclosure budget on purpose: how many characters, and what happens for short or unusual values.
- Compute the fingerprint on the server, so the full value never has to reach the client.
- 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.