Skip to content

API Key setup

Upgrade from "local dev, no auth" to "production-ready scope + limits + audit".

Concept overview

One key, one record; the SHA-256 hash lives in keys.json (plaintext is printed exactly once when generated).

Each key has several kinds of constraints:

  • scope — which endpoints it can call. qot:read / acc:read / trade:simulate / trade:real / trade:unlock / admin.
  • allowed_markets / allowed_symbols / allowed_trd_sides — market / symbol / direction whitelists.
  • allowed_acc_ids — account whitelist (v1.4.84+, per-key restriction on which acc_id values can be touched; see §9 below).
  • max_order_value / max_daily_value — per-order and daily caps.
  • max_orders_per_minute — rate (sliding window).
  • hours_window — time window (local timezone; cross-midnight OK: 22:00-04:00).
  • allowed_machines — soft machine binding (prevents whole-file copy to another host).

REST / gRPC / core WS / MCP all four entry points share the same keys.json — no need to configure it in four places.

1. Install futucli

Same tarball / cargo workspace that produces futu-opend.

./futucli --help

2. Generate a read-only key

./futucli gen-key --id research --scopes qot:read,acc:read

Output:

✅ Generated key "research"
   plaintext: fc_8a3f2b9c1e5d7a4b8c2f9e1d3a5b7c9f  ← save this; the terminal output is your only copy
   stored in: /Users/you/.config/futu/keys.json

Plaintext is printed only once

keys.json only stores the SHA-256 hash. If you lose the plaintext, revoke and regenerate — there's no way to recover it.

3. Generate a trading key with limits

./futucli gen-key \
  --id sim-bot \
  --scopes qot:read,acc:read,trade:simulate \
  --allowed-markets HK,US \
  --allowed-trd-sides SELL \
  --max-order-value 100000 \
  --max-daily-value 500000 \
  --max-orders-per-minute 5 \
  --hours-window 09:30-16:00 \
  --expires 30d

This key can only:

  • Place orders in simulate mode (trade:simulate, not trade:real).
  • Sell, not buy (--allowed-trd-sides SELL whitelist).
  • Trade HK / US.
  • Up to 100k per order, 500k daily.
  • Up to 5 orders per minute.
  • Only between 09:30 and 16:00.
  • Expires after 30 days.

4. Start the gateway with keys.json

./futu-opend \
  --login-account 12345678 --login-pwd 'your_pwd' \
  --rest-port 22222 --rest-keys-file ~/.config/futu/keys.json \
  --grpc-port 33333 --grpc-keys-file ~/.config/futu/keys.json \
  --ws-keys-file ~/.config/futu/keys.json \
  --audit-log /var/log/futu-audit.jsonl

Startup log changes:

INFO  keys_loaded=2 REST keys file loaded (Bearer auth enabled)
INFO  keys_loaded=2 gRPC keys file loaded (Bearer auth enabled)
INFO  keys_loaded=2 WS keys file loaded (Bearer/?token auth enabled)
INFO  audit JSONL logger enabled (target=futu_audit → file)

5. Call each entry point with the key

curl -H "Authorization: Bearer fc_8a3f..." \
     http://localhost:22222/api/accounts
grpcurl -H "authorization: Bearer fc_8a3f..." \
        -d '{"proto_id":1002}' \
        localhost:33333 futu.service.FutuOpenD/Request
# browser / no-header clients: ?token=
websocat 'ws://localhost:44444/?token=fc_8a3f...'

# native clients: Bearer header
websocat -H 'Authorization: Bearer fc_8a3f...' ws://localhost:44444/
# stdio
export FUTU_MCP_API_KEY="fc_8a3f..."
./futu-mcp --keys-file ~/.config/futu/keys.json

# HTTP: client passes Authorization header per call (v1.1+)
./futu-mcp --keys-file ~/.config/futu/keys.json --http-listen 127.0.0.1:38765

6. List / edit / revoke keys

# list all
./futucli list-keys

# revoke one
./futucli revoke-key sim-bot

# edit machine binding in place (no plaintext change)
./futucli bind-key sim-bot --this-machine
./futucli bind-key sim-bot --replace --machines fp_abc123,fp_def456
./futucli bind-key sim-bot --freeze     # temporarily disable
./futucli bind-key sim-bot --clear      # remove binding

Hot reload

You don't need to restart after editing keys.jsonkill -HUP <pid> does it. All four entry points (REST / gRPC / core WS / MCP) support SIGHUP hot reload. New keys work within seconds; revoked keys are rejected immediately.

7. Query the audit log

# recent rejects
jq 'select(.outcome=="reject")' /var/log/futu-audit.jsonl | tail -20

# how many orders did sim-bot place today
jq 'select(.key_id=="sim-bot" and .endpoint|test("order"))' /var/log/futu-audit.jsonl | wc -l

# reject reason histogram (DuckDB works too)
jq -r 'select(.outcome=="reject") | .reason' /var/log/futu-audit.jsonl \
  | sort | uniq -c | sort -rn

8. Prometheus scrape

The /metrics endpoint sits outside the bearer_auth middleware — no token required:

curl http://localhost:22222/metrics
# futu_auth_events_total{iface="rest",outcome="allow",key_id="research"} 1234
# futu_auth_limit_rejects_total{iface="grpc",key_id="sim-bot",reason="rate"} 7
# futu_ws_filtered_pushes_total{required_scope="trade",key_id="research"} 42

In production, use firewall rules or bind to 127.0.0.1 to restrict external access to /metrics.

9. Multi-agent account isolation (v1.4.35+)

For multi-agent / multi-strategy setups (e.g. N LLM-driven bots through MCP), by default any key holding trade:real scope can operate on any unlocked acc_id. v1.4.35 adds a per-key --allowed-acc-ids whitelist:

# bot-A's key: only 10001 / 10002 allowed
./futucli gen-key --id bot-A \
  --scopes trade:real,acc:read \
  --allowed-acc-ids 10001,10002 \
  --max-order-value 5000 --max-daily-value 20000

# bot-B's key: only 10003
./futucli gen-key --id bot-B \
  --scopes trade:real,acc:read \
  --allowed-acc-ids 10003

If bot-A's key tries to place an order on 10003 → daemon immediately returns acc_id 10003 not in allowed list {10001, 10002} without ever hitting the backend.

4-tier isolation strength

--allowed-acc-ids is operational safety, not financial isolation. The distinction matters when choosing an architecture:

Tier Approach Operational isolation Financial isolation Cost Scenario
L1 Single daemon, full-permission key 0 Single-user personal use
L2 Single daemon + multi-key + --allowed-acc-ids Effectively ✅ for cash-only Low LLM multi-agent / multi-strategy
L3 Multiple daemons, same union card ✅ (same as L2) Same as L2 Medium No extra value, not recommended
L4 Multiple daemons, multiple union cards (multiple login_ids) ✅ (backend customer-level) High (×N KYC) Large margin / options / enterprise

Key insight: what L2 buys, and where its limit is

The backend's risk model is customer-level. Under a single union card, Futu backend aggregates total equity / BP / margin / liquidation triggers at the customer (union card) level, not per-session — multiple daemons signing in to the same login_id look to the backend like "one customer with a few open sessions", identical to running the mobile APP + desktop OpenD simultaneously.

But the severity of "financial propagation" depends on account type:

Account / strategy type What happens if one sub-account blows up Propagation?
Pure cash (no margin / no short) Sub-account balance zeros, positions clear; account B untouched No propagation
Margin account, no actual borrowing Behaves like cash No propagation
Margin account + actual borrow + blow through principal A has negative equity; broker may force-liquidate across accounts under unified-account rules ⚠️ Yes
Options combos / cross-product SPAN margin Risk model aggregates margin across sub-accounts ⚠️ Yes

For the mainstream LLM multi-agent scenario (cash-only, small amounts), L2 --allowed-acc-ids is effectively financial isolation — without borrowing, there's no propagation path.

L2 primarily defends against: - Agent code bugs / LLM hallucinations / prompt injection accidentally touching accounts it shouldn't - Key leaks being used to operate on accounts the key was never meant to see - Multi-agent / multi-strategy shared-daemon setups where per-key audit attribution matters

L2 does NOT defend against (these need L4 or other controls): - Actively enabling margin and actual borrowing causing cross-account margin-call propagation - Options combos / futures margin requirements aggregating across sub-accounts - Extreme events (gap / circuit breaker) where risk-based liquidation fails and produces cross-account deficits

Next