Key format
vapk_live_ + 48 hex chars from 24 random bytes).
The vapk_live_ prefix is intentional and has three purposes:
- Format-distinctive: log scrubbers (our error-reporting redactor, log-shipper rules) can pattern-match
vapk_*and drop these strings before they reach stored events. - Cheap shape-validation: middleware short-circuits malformed tokens (random Slack pastes, OAuth-flow
Bearercarryover) before paying for a hash + DB round-trip. - Indexed lookup prefix: the first 22 chars of every issued key are stored in DB un-hashed (
vapk_live_<12 hex>) so verification is a single indexed query plus an scrypt compare.
live) ships. Future per-environment tags would slot into the same shape (e.g. vapk_test_* for sandbox keys) without breaking the format contract.
Issuing a key
1
Sign in at app.vorel.ai
The dashboard uses an authenticated session. You need the
admin or owner role on your tenant
to issue keys.2
Navigate to Settings → Integrations → API keys
The full path:
app.vorel.ai/(dashboard)/settings/integrations/api-keys.3
Click Create API key
Pick a descriptive name (used in audit logs + the key list view) and select the scopes the
integration needs.
4
Copy the key (once)
The plaintext secret is shown ONCE. Subsequent reads see only the prefix + last 4 characters.
Store it in your secret manager immediately. If you lose it, revoke + reissue.
Scopes
Vorel’s API surface is scope-gated. A key with scoperead can call any GET endpoint; mutating
endpoints require the resource-specific write scope.
Issue keys with the minimum scope set the integration needs. A key without the required
scope returns
403 forbidden. The route doesn’t fall through to a less-privileged behaviour.
A key with read only is the right choice for analytics/digest workflows; issue separate keys
for each integration so revoking one doesn’t take down the others.
Making a request
Authorization header carries the full plaintext secret. We extract the prefix, look up the
hashed row, and run an scrypt compare against the candidate. Constant-time hash compare via
timingSafeEqual so an attacker probing for prefix collisions can’t side-channel a partial match.
Storage + hashing
API key storage and verification:- Hashing algorithm: scrypt (Node’s native
crypto.scrypt), N=2^14, r=8, p=1, 64-byte derived key. ~50 ms verification time on an M-class laptop: slow enough to make offline brute-force infeasible on a stolen DB, fast enough to live in the per-request middleware. - Salt: 16 random bytes per key, stored alongside the hash as
<salt>:<hash>. - Last-used tracking: last used at, last used from which address, and last used by which client are recorded on successful verification, throttled and fire-and-forget so a write failure on the bump doesn’t block the request.
- Expiry: a key can carry an expiry date, checked after the hash compare. An expired key fails authentication like a revoked one.
- Revocation: revoking flips the key to revoked. Subsequent verification calls return
401 unauthorizedeven if the secret is otherwise valid. - Budgets: a key can carry a monthly call budget with a soft warning line and an optional hard stop. See Assistant governance.
api_keys.key_hash. The plaintext secret is never persisted:
no log line, no metric tag, no error envelope contains it. Our error-reporting redactor + log
scrubbers pattern-match vapk_* prefixes and drop them as a defence-in-depth.
Rotating + revoking keys
Rotation
Rotation
Rotate a key from the dashboard. Rotation issues the replacement and ends the old key in a
single transaction, inheriting the old key’s scopes verbatim, so a partial failure cannot leave
you with two live keys or none.You choose the grace period. Zero revokes the old key immediately. Any positive value instead
stamps an expiry on it, capped at 30 days, so you can cut your integration over without an
outage.The dashboard surfaces last used, last used from which address, and last used by which client
per key, so you can confirm traffic has moved before the old key lapses.
Revocation
Revocation
Revoke from the dashboard or via the admin API. The mutation is idempotent: re-revoking
an already-revoked key is a no-op (no audit log noise, no error).Once revoked, every call carrying the key returns
401 unauthorized with code='unauthorized'
and message='unknown or revoked api key'. The revoked row stays in DB for forensic purposes;
it’s not purged.Suspected compromise
Suspected compromise
If a key has leaked (committed to a public repo, shared in a screenshot, etc.):
- Revoke immediately. First action.
- Audit usage.
lastUsedAt+ the audit log (actor_type='api_key') tells you what the leaked key did during its window. - Reissue with a fresh secret. Update your integration to use it.
- Optional: rotate any downstream credentials the leaked key could have read (CRM credentials, webhook secrets) if you’re being thorough.
Errors
The error envelope is consistent across every authentication failure mode:
The middleware emits
api_v1.verify_failed log lines on every 401 with the request id (no
key plaintext). Operators can investigate via the audit log + log search.
Security properties (in plain language)
- DB compromise alone doesn’t yield working keys. Hashes leak, but the work factor plus 24 bytes of randomness make offline guessing infeasible at any reasonable scale.
- Stolen-key amplification is bounded. A leaked key gets its own per-API-key rate-limit bucket (200 req/min); it can’t burn through other tenants’ quotas.
- Timing-safe verification. Hash compare uses
timingSafeEqualagainst equal-length buffers. - Per-issuance audit trail. Every issuance, every revocation, every successful verification
with
lastUsedAtupdates land inaudit_logwith the actor + the key id (never the plaintext).
What’s NOT supported today
- OAuth-style end-customer auth. All API keys are tenant-scoped; we don’t ship an end-customer OAuth flow (“let your customers grant access to your data”). The product model is operator-side.
- Hierarchical / sub-keys. No “child” keys with scoped subsets of the parent’s scope. Issue separate top-level keys per integration.
- IP allowlisting. No per-key IP allowlist today. The per-IP-webhook rate limit (500 req/min) is the only IP-shape gate. The address a key was last used from is recorded, so unexpected use is visible after the fact.
- Per-environment key tags beyond
live. Sandbox / test keys would slot into the same shape (vapk_test_*) but aren’t issued today.
Related docs
- API introduction: the high-level surface
- Rate limits: per-key + tenant + IP layered stack
- SDKs:
@vorel/sdktyped client - Security overview: broader posture, RLS, encryption
