Skip to content

Case: Browser-direct frontend

React frontend talks REST + WebSocket, places orders directly from the browser.

Requirements

  • Personal trading dashboard for end users; pure React/Vue frontend, no backend.
  • Real-time quotes + manual order placement (user clicks).
  • HTTPS + self-signed tokens (issued at login, stored in sessionStorage).
  • Browser WS only supports ?token= query parameter (the WebSocket API cannot set custom headers).

Architecture

Browser (React)
  ├── https://api.futuapi.com/api/*        ← REST, Authorization: Bearer
  ├── wss://api.futuapi.com/ws?token=...   ← push (?token= due to browser WS limitation)
  └── User click → POST /api/order
  Caddy (auto HTTPS) → futu-opend (:22222)

Key strategy

Your backend signs a short-lived key at user login (30-minute expiration):

# backend script (simplified)
futucli gen-key \
  --id "user-${USER_ID}-session-$(date +%s)" \
  --scopes qot:read,acc:read,trade:real \
  --allowed-markets HK,US \
  --max-order-value 50000 \
  --max-orders-per-minute 10 \
  --expires 30m

Frontend stores the plaintext in sessionStorage (not localStorage — it auto-clears when the tab closes).

Caddy reverse proxy + CORS

/etc/caddy/Caddyfile
api.futuapi.com {
    # browser frontend CORS
    header {
        Access-Control-Allow-Origin "https://app.futuapi.com"
        Access-Control-Allow-Methods "GET, POST, OPTIONS"
        Access-Control-Allow-Headers "Authorization, Content-Type"
    }
    @preflight method OPTIONS
    respond @preflight 204

    reverse_proxy localhost:22222
}

React client code skeleton

const API = 'https://api.futuapi.com'
const WS_URL = 'wss://api.futuapi.com/ws'

// token from sessionStorage
const token = () => sessionStorage.getItem('futu_token')

// REST helper
async function apiPost<T>(path: string, body: object): Promise<T> {
  const res = await fetch(`${API}${path}`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token()}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  })
  if (res.status === 401) throw new Error('token expired, please log in again')
  if (res.status === 403) throw new Error('insufficient scope')
  if (res.status === 429) {
    const err = await res.json()
    throw new Error(`limit tripped: ${err.error}`)
  }
  if (!res.ok) throw new Error(await res.text())
  return res.json() as Promise<T>
}

// WS push
function useQuoteSubscription(symbols: string[]) {
  useEffect(() => {
    // subscribe first
    apiPost('/api/subscribe', {
      security_list: symbols.map(parseSymbol),
      sub_type_list: [1],  // Basic
    })

    // then open WS (browser can only carry the token via query parameter)
    const ws = new WebSocket(`${WS_URL}?token=${token()}`)
    ws.onmessage = (ev) => {
      const event = JSON.parse(ev.data)
      // { type: 'quote', proto_id, sec_key, body_b64 }
      const body = Uint8Array.from(atob(event.body_b64), c => c.charCodeAt(0))
      const qot = decodeBasicQot(body)
      dispatchQuote(qot)
    }
    return () => ws.close()
  }, [symbols.join(',')])
}

// Place-order button
async function onPlaceOrder(order: OrderForm) {
  try {
    const resp = await apiPost('/api/order', buildPlaceOrderBody(order))
    toast.success(`order submitted, order_id=${resp.order_id}`)
  } catch (e) {
    toast.error(e.message)  // "limit tripped: rate limit exceeded"
  }
}

Security notes

  • Store the token in sessionStorage, not localStorage — it clears when the tab closes.
  • Short expiration — 30-minute auto-expire; frontend refreshes proactively before expiry.
  • Strict CORS whitelistAccess-Control-Allow-Origin must list only your app's origin.
  • CSPContent-Security-Policy: connect-src 'self' https://api.futuapi.com wss://api.futuapi.com.
  • Double-confirm on order placement — make the user click "confirm" once more in the UI.
  • HSTS — Caddy enables it automatically.

What not to do

  • ❌ Embed a long-lived key directly into JS — anyone with devtools open can read it.
  • ❌ Issue a trade:real key with expiration > 1 day.
  • ❌ Skip HTTPS.
  • ❌ Open CORS to all origins.