API key security
How Metric Vault API keys are generated, issued, stored, verified and revoked today, and the operational handling that follows from them being bearer credentials.
Last updated 2026-08-06
Summary#
A Metric Vault API key is a 56-character bearer credential of the form mv_live_ plus 48 hexadecimal characters, generated from 24 cryptographically random bytes: 192 bits of entropy. Creation requires a verified session and the Enterprise plan. There are no scopes, no expiry, and no IP restriction. A key grants one endpoint and spends the owning account's credits.
The operationally important fact: keys are stored as SHA-256 hashes with a short display prefix, and the plaintext exists only in the creation response — it is never written to the database. A key is still a bearer credential with no scopes and no expiry, and everything under Operational handling follows from that.
API keys is the customer-facing task article for creating and revoking keys; Authentication documents the call contract. This page is the security model behind both.
Purpose#
Three groups need this page. Whoever is on call needs to know what a key can do and how fast it can be stopped. Whoever answers a security questionnaire needs the generation, storage and revocation facts without guessing. And whoever works on this subsystem needs the current behavior recorded precisely.
The design constraints that produced the current shape are worth naming. A key acts on behalf of a paying account and spends its credits, so it is deliberately narrow: one endpoint, a hard cap on how many can exist, a plan re-check on every call, and an instant revoke. That narrowness is what limits the blast radius if a key ever leaks on the customer side.
Overview#
| Property | Value |
|---|---|
| Format | mv_live_ + 48 hex characters (56 characters total) |
| Entropy | 24 bytes from crypto.getRandomValues — 192 bits |
| Issued to | The verified session holder, on the Enterprise plan only |
| Active keys per account | 5. Over that: Key limit reached (5 active keys). Revoke one first. |
| Label | Free text, truncated to 60 characters, defaults to API key |
| Scopes | None |
| Expiry | None. A key is valid until revoked |
| IP or origin restriction | None |
| What it can call | POST /api/v1/analyze, and nothing else |
| Cost per call | 6 credits, metered before the analysis runs |
| Storage | SHA-256 hash plus a 12-character display prefix. The plaintext is never stored |
| Revocation | A flag on the row, scoped to the owner. Rows are never deleted |
| Self-service UI | None today. Key management is API-only |
How it works#
Generation and issuance#
POST /api/keys/create takes a Supabase session token and an optional name. The token is verified against the identity provider, which independently requires a confirmed email address, and then requirePlan(..., 'enterprise') runs. Both gates fail closed: an unverified token answers 401 Unauthorized, and a plan lookup that cannot complete answers 503 plan_check_failed rather than permitting the request.
A count of the caller's non-revoked rows is taken before insertion. Five is the ceiling, and revoking a key frees a slot immediately, so rotation does not require a support request.
The full key is returned exactly once, in the creation response, with Cache-Control: no-store. It is not recoverable afterwards through any endpoint, including as an administrator. A lost key is replaced, not retrieved.
Listing and masking#
POST /api/keys/list returns each key as id, name, created_at, last_used_at, revoked and a masked value: the stored 12-character display prefix (mv_live_ plus four hex characters) followed by an ellipsis. No secret material appears in the list view. A legacy key created before hashed storage and not yet used since shows the older mask ending in its last four characters, so treat screenshots of the key list as sensitive until every key on the account has been rotated or used once.
Verification on each call#
POST /api/v1/analyze reads Authorization: Bearer mv_live_…, hashes the presented key with SHA-256 and looks it up by that hash, rejecting a missing or unknown key.
| Condition | Status | Body |
|---|---|---|
No Authorization: Bearer header | 401 | Missing API key. Send header: Authorization: Bearer mv_live_... |
Unknown key, or revoked = 1 | 401 | Invalid or revoked API key |
| Owner no longer on Enterprise | 403 | This feature requires the Enterprise plan. Your account is on <Plan>. Upgrade to unlock it. |
| Account suspended | 403 | This account is suspended. Please contact support. |
| Monthly credit allowance exhausted | 429 | quota_exceeded, with plan, used, quota and reset date |
| No JSON body | 400 | Provide a JSON body: { "url": "example.com" } |
The plan re-check on every call is the important one. Entitlement is not frozen at issuance: a downgrade, a cancellation or a suspension stops every key on the account at the next request, with no key management required. Six credits are metered before the analysis runs, and last_used_at is stamped after it succeeds.
Revocation#
POST /api/keys/revoke sets revoked = 1, with the owner's email in the WHERE clause, so one account cannot revoke another's key even by guessing a row id. Rows are never deleted, which preserves the audit trail of what existed and when it was last used.
Revocation takes effect on the next request. There is no cached verification layer to wait for, and no propagation delay.
Key creation and revocation are both written to the account's activity log as apikey.created (Created an API key) and apikey.revoked (Revoked an API key), under the API Keys module. Listing keys is a read-only route and is not logged.
Storage, stated plainly#
The table is api_keys, keyed by row with key_hash (SHA-256 of the full key), key_prefix (the first 12 characters, for display), owner, name, timestamps and the revoked flag. Insertion writes the hash and the prefix; the plaintext column is written as NULL. Verification hashes the presented key and matches on key_hash.
Keys created before hashed storage landed are migrated on first use: the verify path falls back to a plaintext match for those rows only, then backfills the hash and clears the stored plaintext. A legacy key that has never been used since the migration still holds its raw value until then, which is why the operational rules below treat the table as sensitive.
Operational handling#
These follow from a key being an unscoped bearer credential, and from legacy rows that may still await their first-use migration.
| Practice | Why |
|---|---|
| Treat the key table as a credential store, not a metadata table | A row is a live secret. Restrict who can query the production database and who holds the Cloudflare API token that grants that access |
Never SELECT * the table into a log, ticket, notebook or shared file | The result is a list of usable credentials in a place with different access controls |
| Never paste a customer's key, or a screenshot of the key list, into a support thread | Ask the customer to revoke and reissue instead. It takes them one call and costs nothing |
| Rotate by create-then-revoke | The cap counts active keys only, so a new key can be issued, deployed, verified, and the old one revoked with no window of downtime |
| One key per consuming system | Without scopes, the only way to limit what a compromise reaches is to limit what each key is wired into. It also makes last_used_at interpretable |
| Revoke on any suspicion, immediately | Revocation is instant, free and reversible only by issuing a new key. There is never a reason to wait for confirmation |
| Assume a leaked key spends credits | The cap is the monthly allowance, not a per-key limit. A leaked key on a large plan can consume the account's premium credits before anyone notices |
Watch last_used_at | It is the only usage signal per key. A key that starts being used after months of silence, or one that has never been used, both deserve a question |
| Do not build IP allowlisting expectations | There is none, and none is planned in the current code. If a customer's policy requires source-address restriction, that has to happen in front of their own caller |
What a key cannot do#
Being exact here prevents both over-restriction and false comfort.
A key cannot read the Library, list or delete saved work, change account settings, invite or remove team members, create or read share links, touch billing, reach any administrative endpoint, or create another key. It cannot act on a workspace the owning account was invited to. It calls one analysis endpoint under the owner's identity and spends the owner's credits.
A key can be used from anywhere, by anyone holding it, for as long as the owning account remains on Enterprise and unsuspended, until it is revoked.
Failure modes#
| Situation | Behavior |
|---|---|
| Identity provider unreachable during key create, list or revoke | Fails closed, indistinguishable from a bad token. Key management is unavailable for the duration; existing keys keep working, because analyze calls do not verify a session token |
| Plan lookup unavailable | Fails closed with 503 plan_check_failed. Key creation and key-authenticated calls are both refused rather than permitted |
| Usage lookup unavailable | Fails closed with 503 quota_check_failed |
| Database binding missing entirely | The quota gate allows the request through unmetered. This is the one path where a dependency failure is permissive; see Authorization model |
| Concurrent calls near the quota ceiling | The check and the increment are not atomic, so a burst can exceed the monthly allowance by up to concurrency × 6 credits. Deliberately deferred: the atomic-reservation fix carries double-charge risk across every premium call site |
See also
Was this article helpful?
Thanks — feedback noted for the docs team.