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.

9 min readUpdated 6 August 2026api-keys, security, scopes, credentials, revocation

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:

  1. The key exists. An unknown value is refused.

  2. 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.

  3. The key is not disabled. The disabled flag, with its recorded reason.

  4. The key has not expired. expiresAt in the past is refused.

  5. The client IP is allowed — but only when ipRestriction is on and ipWhitelist is 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 permissionPOST /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_keys verification 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

Revokes one key
Revokes several

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=true brings the row back and the key starts working again. Revocation is reversible by anyone holding delete.api.key.
  • ?force=true removes the row permanently. Use it when you want the credential gone from the database as well as from service.

Confirming a key is dead

  1. Delete it, then reload /admin/api/key. The row leaves the default view; if you are looking at deleted records it badges as Deleted.

  2. 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.

  3. 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 …, or is restricted to … but was used from …. If nothing appears, the request is not reaching this gate.

  4. 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.