sifting/io
Dashboards & Frontend
10 min readSiftingIO Team

How to use a market data API in a web app without exposing your API key

Keep your market data API key out of browser code. Build a server-side quote route with allowed symbols, access checks and safe error handling.

How to use a market data API in a web app without exposing your API key

To use a market data API in a web app without exposing your application's shared API key, keep the key on a server you control and have the browser call your own endpoint, which then calls the provider. There is no front-end-only way to do it, because everything shipped to a browser can be read by the person using it. This post covers what the browser can see, a small server-side route for live quotes that you can run, and the controls that route still needs before customers use it.

What the browser can see#

In a private prototype it's common to call the API straight from front-end code:

// Works on your laptop. Do not ship this.
fetch("https://api.sifting.io/v1/last/quote/forex/EURUSD", {
  headers: { "X-API-Key": "sft_your_key_here" },
});

Anyone who loads that page can open the Network tab in developer tools, click the request, and read the X-API-Key header. They can also search the downloaded JavaScript for sft_. The OWASP AJAX Security Cheat Sheet makes the same point: users can inspect and change client-side data, so private credentials belong on the server.

Three things are often mistaken for protection.

Client-exposed environment variables are the first. Front-end build tools replace them with literal values at build time. The Vite documentation states that variables prefixed with VITE_ "will be exposed in client-side source code after Vite bundling" and should not contain API keys. The Next.js documentation says a NEXT_PUBLIC_ variable is inlined into the JavaScript sent to the browser. That does not mean every environment variable leaks. In Next.js, variables without that prefix are only available on the server by default, and a plain Node process that reads process.env sends it nowhere. What matters is where the code that reads the variable runs.

Minification is the second. It renames identifiers and strips whitespace, and a key is a string literal that survives intact. Splitting or encoding the key doesn't help either: the browser has to reassemble it to send the request, and the Network tab shows the result.

CORS is the third. MDN describes CORS as a mechanism that lets a server tell browsers which origins may read its responses. It is enforced by browsers. curl, a script, or another server never consults it. CORS is not authentication, and an origin allowlist on your endpoint does not tell you who is calling.

A copied key costs you something concrete. Requests made with it count against your plan's monthly quota and rate limit, so your own dashboard can start receiving 429 rate_limit_exceeded while someone else uses your subscription. If a key has already shipped in a bundle or a public repository, treat it as compromised and replace it. Removing the line from the next build does nothing about the copies already downloaded.

Market data API in a web app: the server-side request flow#

The flow has four steps:

  1. The browser requests GET /api/quote/EURUSD on your origin, carrying your app's normal session.
  2. Your server checks the session, the allowed symbol, the user's permission to read it, and the request limit.
  3. Your server calls GET /v1/last/quote/forex/EURUSD on https://api.sifting.io with the X-API-Key header. The key comes from a server-side environment variable.
  4. Your server returns only the fields the UI needs.

Here is that route as a dependency-free Node module. Use a supported Node.js LTS release with built-in fetch and AbortSignal.timeout:

// quote-route.mjs (supported Node.js LTS, no dependencies)
const UPSTREAM = process.env.SIFTING_BASE || "https://api.sifting.io";
const KEY = process.env.SIFTING_KEY; // server-only, never sent to the browser

// The only instruments this dashboard shows. Anything else is a 404.
const ALLOWED = new Map([
  ["EURUSD", "forex"],
  ["BTCUSD", "crypto"],
  ["XAUUSD", "commodities"],
]);

const CACHE_MS = 1000;

function send(res, status, body, headers = {}) {
  res.writeHead(status, {
    "Content-Type": "application/json",
    "Cache-Control": "no-store",
    ...headers,
  });
  res.end(JSON.stringify(body));
}

export function createQuoteHandler({ getUser, canReadQuote, allowRequest }) {
  if (!KEY) throw new Error("SIFTING_KEY is not set");
  if (![getUser, canReadQuote, allowRequest].every((fn) => typeof fn === "function")) {
    throw new Error("Authentication, authorization and rate-limit hooks are required");
  }
  const cache = new Map(); // symbol -> { at, body }, local to this handler

  return async function handleQuote(req, res) {
    const url = new URL(req.url, "http://localhost");
    const match = /^\/api\/quote\/([A-Za-z0-9]{6,12})$/.exec(url.pathname);
    if (req.method !== "GET" || !match) {
      return send(res, 404, { error: "not_found" });
    }

    const symbol = match[1].toUpperCase();
    const venue = ALLOWED.get(symbol);
    if (!venue) return send(res, 404, { error: "unknown_symbol" });

    try {
      const user = await getUser(req); // your session or token check
      if (!user) return send(res, 401, { error: "sign_in_required" });
      if (!(await canReadQuote(user, { symbol, venue }))) {
        return send(res, 403, { error: "not_authorized" });
      }
      if (!(await allowRequest(user, req))) {
        return send(res, 429, { error: "too_many_requests" });
      }
    } catch {
      console.error("quote access check failed");
      return send(res, 500, { error: "request_failed" });
    }

    const hit = cache.get(symbol);
    if (hit && Date.now() - hit.at < CACHE_MS) return send(res, 200, hit.body);

    try {
      const upstream = await fetch(
        `${UPSTREAM}/v1/last/quote/${venue}/${symbol}`,
        {
          headers: { "X-API-Key": KEY },
          signal: AbortSignal.timeout(3000),
          redirect: "error",
        }
      );
      if (!upstream.ok) {
        console.error("quote upstream status", upstream.status, symbol);
        const retry = upstream.headers.get("Retry-After");
        return send(
          res,
          upstream.status === 429 ? 503 : 502,
          { error: "quote_unavailable" },
          retry ? { "Retry-After": retry } : {}
        );
      }
      const q = await upstream.json();
      if (!q || typeof q.b !== "string" || typeof q.a !== "string" ||
          !Number.isSafeInteger(q.t) || (q.s !== undefined && q.s !== symbol)) {
        throw new Error("invalid_quote");
      }
      const body = { s: symbol, b: q.b, a: q.a, t: q.t };
      cache.set(symbol, { at: Date.now(), body });
      return send(res, 200, body);
    } catch (err) {
      console.error("quote upstream failure", err?.name || "Error", symbol);
      return send(res, 502, { error: "quote_unavailable" });
    }
  };
}

The route does one job. The browser supplies a symbol and nothing else, and the server builds the upstream URL from fixed parts. There is no url parameter and no path passthrough. A relay that forwards arbitrary paths or URLs with your key attached gives every visitor your whole subscription, and it can be pointed at addresses inside your own network.

The ALLOWED map is both the input check and the venue lookup. The OWASP cheat sheet also recommends treating every input as untrusted, including inputs to services intended only for your own front end. A symbol outside the map gets a 404 before any upstream call is made.

The key is read from process.env at runtime and sent in the X-API-Key header. The SiftingIO quickstart accepts an ?api_key= query parameter too, and prefers the header because query strings can leak in logs.

Redirects are refused, so an upstream redirect cannot carry the key to another destination. A hung upstream request is cut off after three seconds. Upstream error bodies and status codes are never forwarded: the status goes to the server log and the browser gets a generic quote_unavailable. An upstream 429 becomes a 503 with the Retry-After value passed through, so the front end knows how long to back off.

The one-second cache lets later requests reuse a quote after the first fetch completes. It does not combine simultaneous cache misses: fifty requests arriving together at an empty cache can still trigger fifty upstream calls. Each handler instance has its own cache, so this is not a quota guarantee. Add in-flight request coalescing and a shared cache if your traffic needs them.

Every cache hit still passes the access checks. Sharing by symbol is appropriate here because authorized users receive the same fields from the same provider account. If users have different data entitlements or upstream credentials, partition the cache accordingly.

What the proxy does not do for you#

getUser, canReadQuote and allowRequest are left to you on purpose. Creating the handler without all three throws an error. If a hook fails during a request, the handler returns a generic 500 and makes no upstream call. A proxy with no authentication only moves the problem: the key stays hidden, and anybody can still call /api/quote and spend your quota. A real deployment has to supply these parts:

  • Authentication. getUser validates the session cookie or token your app already issues and returns null for everyone else.
  • Authorization. canReadQuote checks whether this user may see the requested symbol and venue, for example by plan or account state. It runs before the cache is read.
  • Abuse controls. allowRequest enforces a per-user limit, and ideally a per-IP one, backed by a shared store if you run more than one instance. Watching the X-RateLimit-Remaining header on upstream responses gives early warning that something is draining the budget.
  • HTTPS on your own origin, so the session that guards the route can't be read in transit.

Wiring it up looks like this. It is a sketch and will not run as printed, because the authentication, authorization and rate-limit modules are your own code:

import http from "node:http";
import { createQuoteHandler } from "./quote-route.mjs";
import { getUserFromSession } from "./your-auth.js";
import { canReadQuote } from "./your-authorization.js";
import { perUserRateLimit } from "./your-rate-limit.js";

http
  .createServer(
    createQuoteHandler({
      getUser: getUserFromSession,
      canReadQuote,
      allowRequest: perUserRateLimit,
    })
  )
  .listen(3000);

One question sits outside the code. Whether you may display the data to your own customers depends on your plan and agreement with the provider. The proxy pattern does not answer it, so check the terms before launch.

The same reasoning applies to streaming. The SiftingIO WebSocket authenticates with a ?key= parameter in the connection URL, so a socket opened directly from the browser shows the key in the same Network tab. Open the socket from your server and relay to your clients. The trade-off between polling a snapshot and streaming is covered in Real-time FX and crypto quotes: REST snapshots and WebSocket streams.

Worked example with fixture data#

The module above was rechecked on 2026-09-21 with a stubbed fetch, the fixture key sft_fixture_not_a_real_key, and test implementations of the three access hooks. No request was sent to the production API. This validates the handler's control flow, not your application's session system or the live service. Every value below is synthetic. The field names follow the documented last-quote response, and t is an arbitrary instant (2026-09-21T12:00:00Z).

The fake upstream returned:

{ "s": "EURUSD", "b": "1.16925", "B": "510300", "a": "1.16943", "A": "585025", "t": 1789992000000 }

The browser received:

{ "s": "EURUSD", "b": "1.16925", "a": "1.16943", "t": 1789992000000 }

The other cases behaved as follows:

RequestResult
/api/quote/EURUSD with no session401, no upstream call
/api/quote/eurusd signed in200, trimmed body above
Same request again within one second200 from cache, upstream count unchanged
/api/quote/AAPLXX404, no upstream call
/api/anything?url=http://evil.example404, no upstream call
Upstream answers 429 with Retry-After: 2503 with Retry-After: 2, body quote_unavailable
Upstream answers 401502, body quote_unavailable, status in the server log only
Upstream never answers502 after about 3 seconds
allowRequest returns false429, no upstream call
canReadQuote returns false, even with a cached quote403, no upstream call
An access hook throws500 with a generic error, no upstream call
Invalid JSON or an unexpected quote shape502, no upstream body returned

The key string appeared in no response body.

On the front end, remember that the documented quote response carries prices and sizes as strings, so cast before doing math. With the fixture, Number("1.16943") - Number("1.16925") rounds to 0.00018 at five decimals, a spread of 1.8 pips. Turning that raw quote into a client you can trust, from casting the string fields to handling the WebSocket keepalive, is its own subject, and Live bid and ask price API: build a correct EURUSD and BTCUSD quote client walks through it.

Endpoint paths, response fields and error codes are in the SiftingIO documentation. Read the docs

Keep reading

Related posts