API key scopes: least privilege for trading accounts

8 min readMetaKit

Somewhere in your Slack history there's an API key pasted into a DM so a colleague could "just check something". If that key can create a trade copier on a funded account, it is a trading credential, and it has been sitting in a chat log for four months.

Trading accounts are unusually high-stakes credentials. A leaked key to a SaaS analytics tool costs you embarrassment; a leaked key to something that can move positions costs you money, in real time, while you're asleep. This post applies least privilege to that specifically: what API key scopes exist between you and an MT5 account, which one each consumer should get, and what each one can do in the wrong hands.

API key scopes are one of three gates

Between a script and an MT5 account there are three independent credentials, and they gate different things. Confusing them is how people end up with a "read-only" key that can do more than they think.

LayerSet whereOptionsGates
MT5 passwordAt the brokerinvestor / masterWhether the terminal itself can trade
MetaKit slot tierWhen the account is connectedreadonly / fullWhether MetaKit can trade or copy onto this account
API key scopeWhen the key is createdreadonly / fullWhich HTTP methods the key may call

The investor vs master password post covers the first layer. Short version: the investor password is the broker's own read-only mode, and an account connected with it physically cannot place an order, whatever software sits in front of it.

The slot tier is fixed when you connect. A readonly slot uses the investor password and can't be a copier follower. A full slot uses the master password and can.

The key scope is the one this post is about. A readonly key can make GET requests only. A full key can also POST, PATCH, PUT and DELETE. A write with a readonly key returns 403 with code insufficient_scope.

The gates stack. A full key cannot trade on a readonly slot; a full slot does nothing for a readonly key. And the fourth thing, key expiry, sits on top of all of it: a key past its date fails every call with 401 key_expired.

Which key each consumer gets

The rule is boring and correct: the narrowest scope that does the job, with the shortest expiry you can tolerate, one key per consumer.

Dashboards and reporting. readonly. A P&L table, an equity logger, a Grafana poller: all of these only ever GET. Give the key a 90-day expiry so it dies on its own if the dashboard is abandoned.

Analysts and notebooks. readonly, one key per person, 30-day expiry. When someone leaves, revoke their key and nothing else changes. When their laptop is stolen, same.

AI agents. readonly, always, no exceptions. An agent that can read positions and history can answer nearly every question you'd ask it. An agent that can also create copiers is an agent that can be talked into creating copiers. The AI agent post goes into why prompt injection makes this non-negotiable.

CI. readonly for tests that read fixtures from a demo account. If a pipeline provisions accounts, that job gets its own full key with a short expiry, stored as a CI secret, used by nothing else.

A copier setup script. full, because creating a copier is a POST. Make the key with a one-day expiry, run the script, and let it die. The copier keeps running; the credential that created it doesn't need to exist anymore.

Your own backend. full, if it manages accounts or copiers. This is the one key that needs to be long-lived, which means it's the one you rotate on a schedule and the one that never leaves the secrets manager.

What a leaked readonly key can do

More than nothing, and you should know exactly what.

It can read every account you've connected: balances, equity, margin, open positions with their stop losses, complete deal history, computed performance, and the broker's symbol specs, ticks and candles through each account's feed. That's your whole trading book and your whole track record. For a money manager, that's the confidential part.

It can list monitors and copiers, so an attacker learns your risk limits and which accounts mirror which.

It can call GET /v1/webhooks, which returns each webhook's signing secret. That's the one that surprises people. With a signing secret, someone can forge deliveries to your receiver that verify correctly. If your receiver acts on events by trusting the payload, a readonly key leak becomes "an attacker can tell your system a position closed". If your receiver reconciles by fetching state from the API instead, forged events are noise. (One more reason to build it that way; see the delivery semantics post.)

It cannot place a trade, change anything, buy a slot, or read a password: MT5 passwords are write-only and never returned by any endpoint, and monitor channel_config (your Slack or Telegram credentials) is never returned either.

So a leaked readonly key is a confidentiality problem, plus a webhook integrity problem if your receiver is naive. Rotate the webhook secrets when you revoke the key.

What a leaked full key can do

Everything above, plus every write. Start with the obvious one: on any account connected on a full slot, a full key can call POST /v1/accounts/{id}/orders, move stops, and close positions. One call, and a market order is at the broker. That is the whole reason the order endpoints refuse readonly keys and readonly accounts before doing anything else.

The less obvious damage is worth walking through too, because it applies even to accounts a key cannot trade directly. A full key can POST /v1/copiers. If you have a full account connected, someone with the key can point a copier at it as the follower. They need a source account they control, and the key can only see your accounts, so they POST /v1/accounts with their own MT5 login. That needs a free slot; if there isn't one, POST /v1/slots buys one, charging your saved card. Three calls, and their trades are being replicated onto your money.

Less dramatic and still bad: PATCH an existing copier with reverse: true. DELETE a copier so its positions become unmanaged. DELETE a monitor so your drawdown guard is gone. PATCH a monitor's channel_config to point alerts at a URL they own. POST /v1/webhooks/{id}/rotate to break your receiver. DELETE /v1/accounts/{id} to disconnect an account, which leaves positions open at the broker with nothing watching them.

None of those is "placing an order", and every one of them changes what happens to real positions. Treat a full key exactly the way you treat the master password.

The slot tier is the gate the key cannot open

The order endpoints check two things that a stolen key does not change: the key's own scope, and the slot tier of the account it targets. A readonly key is refused with 403 insufficient_scope. A full key pointed at an account connected on a readonly slot is refused with 403 account_readonly, and would fail at the terminal anyway, because that terminal is logged in with the investor password and physically cannot trade.

So the honest framing is this. An account you only ever read from belongs on a read-only slot, and then no key of any scope can trade it. An account you execute on needs a full slot, and the full key that goes with it is a trading credential: one call from a market order. Store it like one, scope it to one integration, give it an expiry, and never let it near an agent that does not need to execute.

Rotation and expiry, in practice

Keys are shown once at creation and stored hashed, so there's no "reveal key" later. That's a feature: if you don't have it, neither does anyone else.

Rotation is create-deploy-revoke: make the new key, update the consumer, confirm it's making requests, revoke the old one. Because each consumer has its own key, this is surgical. Rotating the Grafana poller's key doesn't touch the backend.

Expiry is rotation you can't forget. When a key hits its date every call fails with key_expired and a message containing the date. Your logs will tell you, loudly, that the analyst's key from March is still in use somewhere. That's the point. Set expiries on everything except the backend key, and put the backend rotation on a calendar.

One more signal worth alerting on: insufficient_scope in your logs means a readonly key just tried to write. Either a consumer has a bug, or someone is probing what a key they found can do.

Audit your keys

Fifteen minutes, quarterly, in the dashboard under Settings → API keys and with a readonly key in a terminal:

  1. For every key: name the consumer that uses it. If you can't, revoke it.
  2. Check the scope against the consumer's job. A dashboard with a full key gets a new readonly key today.
  3. Check expiry. Anything without one that isn't the backend gets one.
  4. GET /v1/webhooks: every URL should be yours. Rotate any secret you've ever pasted anywhere.
  5. GET /v1/copiers: every copier should be one you created, with the source_account_id and follower_account_id you expect.
  6. GET /v1/monitors: your guards are still there and still armed.
  7. GET /v1/slots?status=active: no slots you didn't buy.

Steps 4 to 7 are the "did anything already happen" check, and they're cheap because they're all reads.

The scope and expiry options are documented under Authentication in llms.txt; the dashboard is at app.metakit.cloud. Go find that key in Slack and revoke it.