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, notlocalStorage— it clears when the tab closes. - Short expiration — 30-minute auto-expire; frontend refreshes proactively before expiry.
- Strict CORS whitelist —
Access-Control-Allow-Originmust list only your app's origin. - CSP —
Content-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:realkey with expiration > 1 day. - ❌ Skip HTTPS.
- ❌ Open CORS to all origins.