API keys
The admin side of /admin/api/key — what each field means, why a key acts as its owner, why scopes are not a containment boundary on user routes, and how to revoke one and prove it is dead.
An API key is a row in api_key that authenticates as the account it belongs
to. It is a credential, not a setting, and the single most important thing to
know about it is on the next line.
When a request arrives with an x-api-key header, the platform looks the key
up, reads its userId and its owner's roleId, and sets those as the caller
for the rest of the request. Nothing narrows what that caller may do beyond what
its owner may do.
So a key on a customer's account can do what that customer can do. A key on an administrator's account can reach admin routes, gated by that administrator's role exactly as a browser session would be. Issuing a key against a privileged account is issuing that account.
The customer-facing view of the same feature — what a key is for, how a customer creates one on their own profile — is a separate document. This page is the admin screen, the enforcement, and the revocation.
The screen
Users → API Management, /admin/api/key.
| Action | Permission |
|---|---|
| Open the page | access.api.key |
| Load rows | view.api.key |
| Create | create.api.key |
| Edit | edit.api.key |
| Delete | delete.api.key |
POST /api/admin/api accepts a userId in its body, so an operator holding
create.api.key can mint working credentials against any account on the
platform, including one with scopes that move money. Grant it only to roles
you would trust with the accounts themselves.
The table lists the key's ID, its owner (avatar, name, email), its name, the masked key, its scopes, whether IP restriction is on, and the creation date. Expand a row or open the view dialog for the rest of the credential: the fields that decide whether a key is still dangerous — expiry, last use, disabled state, rate-limit overrides — have no column of their own.
The view dialog badges a key as Active, Disabled, Expired or Deleted, and adds an IP restricted badge where a list exists.
What the fields mean
| Field | Column | Set from | What it does |
|---|---|---|---|
name |
Name | create/edit dialog | label only; nothing reads it |
key |
Key | server-generated | the credential. 64 characters, ~381 bits of entropy |
secret |
— | Hummingbot addon only | HMAC signing secret; never returned by any read |
type |
Key Type | not on the screen | user (default) or plugin |
userId |
User | not on the create dialog | the account the key acts as |
permissions |
Permissions | create/edit dialog | the scopes — see below |
ipRestriction |
IP Restriction | create/edit dialog | switch; does nothing without a list |
ipWhitelist |
IP Whitelist | create/edit dialog | the addresses the key may be used from |
expiresAt |
Expires | no screen sets it | enforced when present |
disabled + disabledAt / disabledBy / disabledReason |
Suspension | Hummingbot addon only | refuses the key with a recorded reason |
lastUsedAt / lastUsedIp |
Usage | written by the platform | stamped at most once a minute per key |
rateLimitOverride |
Rate limit overrides | Hummingbot addon only | per-route limits replacing the defaults |
key is server-generated on both create and update paths, and the update
endpoint explicitly ignores a submitted key and a submitted userId — the
second so an admin cannot move an existing key onto somebody else's account.
Two things combine badly:
The create dialog has no owner field. It collects a name, a "Key" box, the
scopes and the IP settings — but no userId. The endpoint accepts one; the
form never sends one. The row is stored with userId NULL, and a key with no
owner authenticates as nobody: the caller's id resolves to nothing and every
route that reads it fails.
The plaintext is never shown. POST /api/admin/api answers with
{ message: "apiKey created successfully" } and nothing else, and both the list
and the detail endpoints mask key to •••••••• plus its last four characters.
There is no screen and no response anywhere that will ever show you the value
you just created.
So: do not issue credentials from this screen. Have the customer create the
key themselves on their own profile, where the full value is shown once at
creation — or call POST /api/admin/api directly with an explicit userId in
the body and read the key out of the database, accepting that you have just put
a live credential through your own tooling.
Use /admin/api/key for what it is genuinely good at: seeing every key on the
platform, who owns it, what it may reach, when it was last used, and taking it
away.
What is enforced on every request
Before a key is accepted, five checks run in order. Any one of them refuses the request with 401 — the caller is never told which:
-
The key exists. An unknown value is refused.
-
The owner's account is
ACTIVE. A key is its owner's credential, so suspending or banning the account kills every key on it at once. This is the fastest way to stop all of a customer's automation. -
The key is not disabled. The
disabledflag, with its recorded reason. -
The key has not expired.
expiresAtin the past is refused. -
The client IP is allowed — but only when
ipRestrictionis on andipWhitelistis non-empty. Addresses are normalised first, so an IPv6-mapped IPv4 (::ffff:1.2.3.4), a bracketed form and an IPv4 with a trailing port all compare equal to the plain address.
ipRestriction defaults to true on keys a customer creates, while ipWhitelist
defaults to []. An empty list is treated as "not configured" rather than
"deny everything" — otherwise every key created on the default would be dead on
arrival. So a key showing IP restricted with no addresses beside it is not
restricted at all. Check the list, not the badge.
A successful check also stamps lastUsedAt and lastUsedIp, throttled to once
a minute per key and fire-and-forget. That pair is the only way to tell a live
key from a forgotten one, and it is the column to sort by before a clear-out.
Scopes, and where they stop being a boundary
The scopes a key may hold are a fixed list of five, validated server-side against an allowlist so a client cannot self-grant anything else:
| Scope | Route prefixes it guards |
|---|---|
trade |
/api/exchange/order, /api/ecosystem/order |
futures |
/api/futures |
deposit |
/api/finance/deposit |
withdraw |
/api/finance/withdraw |
transfer |
/api/finance/transfer |
The payment-gateway extension adds three more of its own —
gateway.payment.create, gateway.payment.status and gateway.refund.create —
mapping to /api/gateway/v1/*.
The gate that compares a key's scopes against the route it is calling is
rolesGate, and its very first line is:
if (!metadata || !metadata.permission) return next();Customer-facing routes declare requiresAuth: true and no permission —
POST /api/exchange/order, POST /api/finance/transfer and the deposit and
withdrawal endpoints all do. The gate therefore returns before it looks at the
key at all, and the scope array is never consulted on those paths.
The practical consequence: a key ticked for deposit only is not prevented from
placing an order or requesting a withdrawal. Do not use a narrow scope as a
containment boundary on customer routes. Treat the scopes as a statement of
intent — useful for auditing what a key was issued for — and rely on the
controls that are genuinely enforced: the owner's account status, the IP
allow-list, expiry, and deletion.
There is a second, stricter path in the router — a plugin verification gate that
refuses any key whose type is not plugin and then enforces the maps above
before the handler runs. A route opts into it by declaring requiresApi: true
in its metadata, and no route in the platform declares it. That gate never
runs today.
An API key is not a role
They are two unrelated systems and the Roles screen does not touch keys.
| Role permissions | API key scopes | |
|---|---|---|
| Shape | verb.domain.resource, 710 seeded keys |
five fixed strings |
| Granted on | /admin/crm/role, per role |
the key itself |
| Checked against | the route's declared permission |
a route prefix map |
| Applies to | every admin route | six user route prefixes, and only where the route declares a permission |
| Changing it | live at once on the thread that saved it, everywhere within a minute | effective on the next request |
A key does not carry permissions of its own beyond those scopes. What it may
reach is decided by its owner's role: the key's owner and their roleId are
read from the database on every request, and that role's permission list is then
looked up in a map each backend thread keeps. That map is not a boot snapshot —
saving a role reloads it immediately on the thread that handled the save, and
every other worker thread re-reads roles and permissions on a 60-second timer.
So moving a key's owner onto a smaller role narrows the key immediately, and
editing the role's permissions takes effect at once for the operator who made
the change and within a minute for everyone else — no restart, exactly as for a
browser session. See Roles and permissions.
Limits on customer-created keys
A customer creating their own key passes three gates the admin screen does not:
- KYC. The
api_keysverification feature. While per-feature enforcement is switched off, a legacy rule requiring verification level 2 or higher applies instead, so upgrading does not silently open this door. See KYC levels and features. - Ten keys per account, counted across every key on the
userId— a hard-coded cap, not a setting. - The scope allowlist, which rejects anything outside the five above.
None of these apply to POST /api/admin/api.
Revoking a key
Delete is the control you have on this screen; there is no disable button.
The api_key table is paranoid, so a delete is a soft delete — the row
keeps its data and gains a deletedAt. That is enough to stop the key working:
the lookup on the authentication path excludes soft-deleted rows, so the very
next request carrying that key is refused with a 401.
Two consequences follow:
- A revoked key can be restored.
DELETE …?restore=truebrings the row back and the key starts working again. Revocation is reversible by anyone holdingdelete.api.key. ?force=trueremoves the row permanently. Use it when you want the credential gone from the database as well as from service.
Confirming a key is dead
-
Delete it, then reload
/admin/api/key. The row leaves the default view; if you are looking at deleted records it badges as Deleted. -
Make one call with it. Any authenticated endpoint with
x-api-key: <the key>should answer 401. A 403 means the key still authenticated and was refused on permissions — it is not dead. -
Check the backend log. Every refusal writes a warning naming the key id and the reason:
unknown key,belongs to a … account,is disabled,expired at …, oris restricted to … but was used from …. If nothing appears, the request is not reaching this gate. -
If it is urgent, stop the account instead. Blocking the owner refuses every key on that account immediately, and does not depend on you having found all of them. See The user desk.
A socket authenticated at upgrade time keeps its frame pipe until it drops. For an incident, confirm the account has actually gone quiet rather than assuming deletion ended everything instantly.
Hummingbot keys share this table
The secret, disabled, rateLimitOverride and audit-log fields on the model
belong to the Hummingbot extension, which issues HMAC-signed keys on the same
api_key table with a fuller lifecycle: rotate, disable with a reason, enable
again, per-route rate limits, and an append-only audit trail
(api_key_audit_log) recording key creation, secret rotation, replay and clock
skew rejections, scope refusals and kill-switch events.
Those live behind /api/admin/hb/keys/* (permissions view.hb.key and
friends). The Hummingbot admin has no keys screen — it ships /admin/hb,
/admin/hb/settings, /admin/hb/strategies, /admin/hb/instances and
/admin/hb/command, and nothing else. The endpoints exist, the permission keys
are seeded, and nothing in the shipped frontend opens them. If you run
Hummingbot, that lifecycle is reachable only from the customer-facing console or
by calling the API.
A key issued by that addon still authenticates through the same five checks above, so everything on this page applies to it as well.