Skip to content

Auth & limits

Legacy mode (no keys.json) behavior

v1.4.86 behavior change (partial breaking):

Mode Read-only endpoints (/api/quote / /api/positions / ...) Write / admin endpoints (/api/order / /api/modify-order / /api/unlock-trade / /api/reconfirm-order / /api/cancel-all-order / /api/admin/*)
Scope mode (keys.json configured) Requires matching scope token Requires trade:real / admin scope token
Legacy mode (no keys.json) ✅ Allowed without auth (backward compat) 401 UNAUTHORIZED (new in v1.4.86)

Why: Any local skill / agent / shell script could previously curl POST /api/order without auth, which is a security hole (external reviewer confirmed 2026-04-23). v1.4.84 only printed a startup stderr warning; v1.4.86 hard-gates it.

Upgrade guide: If you previously used legacy mode + write endpoints (/api/order etc.):

# 1. Generate an API key
./futucli gen-key --id my-agent --scopes qot:read,acc:read,trade:real
# → prints fc_xxxxx plaintext (shown once, save on client side)
# → writes to ~/.futu-opend-rs/keys.json (or --rest-keys-file path)

# 2. Restart daemon with --rest-keys-file
./futu-opend --login-account X --login-pwd Y \
    --rest-port 22222 \
    --rest-keys-file ~/.futu-opend-rs/keys.json

# 3. Client passes the token
curl -H "Authorization: Bearer fc_xxxxx" \
    -X POST http://localhost:22222/api/order \
    -d '{"c2s": {...}}'

Read-only use is unaffected: if you only call /api/quote / /api/positions / /api/orders query endpoints, keep skipping keys.json configuration — legacy still allows them.

Five scopes

Scope What it unlocks
qot:read Quote read + subscribe
acc:read Account read-only (funds / positions / orders / fills)
trade:simulate Simulated-account place / modify / cancel
trade:real Real-account place / modify / cancel
trade:unlock Used by the MCP futu_unlock_trade tool (reads the password from OS keychain, never exposed to the LLM)
admin v1.4.32+ daemon management endpoints (/api/admin/status|reload|shutdown); grant only to ops keys, never to LLM keys

keys.json format

{
  "version": 1,
  "keys": [
    {
      "id": "research",
      "hash": "sha256 hex",
      "scopes": ["qot:read", "acc:read"],
      "limits": {
        "allowed_markets": ["HK", "US"],
        "allowed_symbols": null,               // null = unlimited
        "max_order_value": 100000.0,
        "max_daily_value": 500000.0,
        "hours_window": "09:30-16:00",
        "max_orders_per_minute": 5,
        "allowed_trd_sides": ["SELL"]
      },
      "allowed_machines": ["fp_abc123"],       // null = not machine-bound
      "created_at": "2026-04-15T10:00:00Z",
      "expires_at": "2026-05-15T10:00:00Z",    // null = never expires
      "note": "research bot"
    }
  ]
}

Full field details: keys.json fields.

Seven-layer limits

An order request passes through each gate in order (any reject returns an error):

1. Market whitelist      allowed_markets       — is ctx.market in the list?
2. Symbol whitelist      allowed_symbols       — is ctx.symbol in the list?
3. Direction whitelist   allowed_trd_sides     — BUY / SELL / SELL_SHORT / BUY_BACK
4. Time window           hours_window          — local tz; cross-midnight OK: 22:00-04:00
5. Per-order cap         max_order_value       — qty × price
6. Rate limit            max_orders_per_minute — 60-second sliding window
7. Daily cap             max_daily_value       — resets at UTC midnight

Coordination between the auth layer and the handler layer:

  • Auth layer (middleware): market / symbol / side / value are unavailable (body not parsed). Runs only the rate + time-window global gate and commits a rate-window timestamp.
  • Handler layer (business router): after body decode, full context exists. Calls check_full_skip_rate to run the remaining checks — skipping rate (already committed, avoid double-counting), but daily value still accumulates.

This way rate is counted once and daily value accumulates honestly.

Soft machine binding

# bind to this machine
./futucli gen-key --id bot --scopes trade:real --bind-this-machine

# cross-machine: on the target machine
./futucli machine-id --for-key bot
# prints fp_xxxxx

# on the issuing machine
./futucli gen-key --id bot --scopes trade:real --bind-machines fp_xxxxx

Fingerprint formula: SHA-256("futu-machine-bind:v1:" + key_id + ":" + raw_machine_id).

raw_machine_id sources: - Linux: /etc/machine-id - macOS: IOPlatformUUID (via ioreg) - Windows: not yet implemented

Soft binding strength: does not stop an attacker who has logged into the target machine (they can read machine-id and forge the fingerprint); it does stop an accidental leak where the whole keys.json is copied to another host. Production-grade strong binding should use TPM / Secure Enclave (future release).

SIGHUP hot reload

kill -HUP <pid> re-reads keys.json. All four entry points (REST / gRPC / core WS / MCP) support it:

  • Newly-added keys work within seconds
  • Edits to scope / limits / expires_at / allowed_machines take effect immediately (no restart)
  • A remove-key'd key is rejected on the next request (no TTL wait)

Implementation: KeyStore uses ArcSwap<KeysFile>; SIGHUP → re-read file → atomically swap the snapshot. The request path fetches the current snapshot via get_by_id(key_id).

Audit

Every allow / reject / trade event flows through futu_auth::audit:

WARN target=futu_audit iface=rest endpoint=/api/order
     key_id=bot_a outcome=reject reason="limit: rate limit exceeded: ..."

Use --audit-log <PATH> to write target=futu_audit events to a separate JSONL file, then post-process with jq / DuckDB:

jq 'select(.outcome=="reject" and .iface=="grpc")' /var/log/futu-audit.jsonl

Fail-closed design (against attackers)

Scenario Behavior
/api/unknown-path 404 (don't leak whether the endpoint exists, and don't suggest "try another key")
tools/call for an unregistered tool Reject "unknown MCP tool"
gRPC proto_id not in our map Default to trade:real scope (conservative)
Per-call api_key invalid Reject (do not fall back to the startup key — prevents privilege escalation)
Key revoked after handshake Next request rejected with "key revoked"

F3C Auth error code reference (ret_type semantics)

Added in v1.4.84 §12 (in response to dual-tester feedback that the same account across different versions sees different ret_type values — needs a semantic reference). Daemon receives auth responses from auth.futunn.com / auth.moomoo.com in POST /authority/; the ret_type / error_code meanings (aligned with C++ FTLogin convention):

ret_type Name Meaning Trigger Daemon behavior
0 Success Login OK TGTGT verified + device verified Save tgtgt_new / device_sig_new / rand_key_new to credentials file, start server
2 Password / TGTGT sig mismatch pwd_md5 wrong OR TGTGT misconstructed (client_type / account split / svr_time endian etc.) Futu protocol misleading: looks like "wrong password", 99% TGTGT construction issue Prompt user: check password + platform (futunn vs moomoo) + --reset-device
11 require_verify_code TGTGT sig verification failed (not a real captcha request) Missing region_no in account split / missing salt URL fields / missing payload fields Verify 15 fields in POST /authority/ body (empirical)
15 tgtgt expired 3 independent causes:
(a) device_id poisoned (empty SMS submission pre-v1.4.16)
(b) server throttling (same uid repeating POST /authority/)
© account-level state anomaly (needs App login to activate)
Not really "expired", 99% (b) throttling Daemon prompts in 3 categories: sleep 60s retry → --reset-device → login via App once
20 require_device_verify Normal SMS flow (first login from new device); response carries device_verify_sig / phone_no New device_id / credentials file doesn't exist Daemon auto-calls req_device_code → SMS sent → prompt user for --verify-code
21 Verify code wrong SMS code wrong or empty (non-interactive stdin fail) User typed wrong / setup-only non-TTY stdin empty v1.4.76 atty check guard + v1.4.81 Option B cached dcs retry path
23 Need phone bind Account hasn't bound phone in App New account without App first-login activation Prompt user to complete phone binding in Futu/moomoo App
45 App version too low X-Futu-Client-Version < 800 Overseas accounts strictly check version Rust hardcodes 1002 (= C++ OpenD FTGTW_Client_Version = 10.02 × 100), shouldn't trigger
99 Network request param error body signature verification failed / field missing / invalid value Deleting required fields like sens_state (empirical, sens_state-class required fields), or body tampered Don't modify daemon hardcoded fields

Cross-version ret_type stability

The same account may see different ret_type values across versions — this is not necessarily a daemon regression:

Cross-version difference Most likely cause
20 → 11 device_id changed (--reset-device or file deleted) → server needs re-SMS verification
20 → 15 Throttling hit (short-period POST /authority/ exceeded threshold) / or device_id invalidated by App
11 → 15 TGTGT payload difference (version fixed a field) + server throttling
2 → 11 Platform switched (--platform futunnmoomoo) causing wrong client_type 40/60
15 → 20 After --reset-device, server sees new device and starts fresh SMS flow

Recommendation: When real-machine verifying and seeing cross-version ret_type flip, first sleep 300s to clear the throttle window, then confirm: - Version number (futu-opend --version) - --platform value - Whether credentials file was modified externally - Whether --reset-device was run recently

2FA error codes (/api/unlock-trade response)

Token 2FA (Futu token / moomoo token) backend result_code after /api/unlock-trade (non-HTTP-auth path):

result_code Meaning Fix
-20011 Account needs token 2FA verification Use the security_firm's corresponding token app (Futu token vs moomoo token, secrets incompatible) + 2FA enabled for account
-102 Channel doesn't support Account not unlocked / no permission / broker not connected
110005 Contract field inconsistency Check security_type + exchange_str + sec_market (empirical)