DEV Community

Alper San
Alper San

Posted on Originally published at sifting.io

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. Have the browser call your endpoint, then let your server call the provider.

There is no front-end-only way to hide that key from the person using the browser. Moving it into a front-end environment variable changes where you type it, not who can read it.

This guide uses SiftingIO's quote endpoint for the examples. If you want to try the provider-specific call, create a SiftingIO account and get an API key. Keep it on the server. The same architecture applies to other services with private API keys.

Republished from the SiftingIO engineering blog, with formatting and verification notes updated for DEV.

What the browser can see

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

// A private prototype, not something to ship.
fetch("https://api.sifting.io/v1/last/quote/forex/EURUSD", {
  headers: { "X-API-Key": "sft_your_key_here" },
});
Enter fullscreen mode Exit fullscreen mode

Anyone loading the page can open the Network tab, inspect that request and read the header. They can also search the downloaded JavaScript.

Three things are often mistaken for protection:

Approach Why the key is still exposed
Client-exposed environment variables The build includes the value in client-side code. Vite's VITE_ variables and Next.js's NEXT_PUBLIC_ variables are examples.
Minification or encoding The browser still needs the usable key to send the request. The request exposes it.
CORS Browser origin rules are not authentication. A script or another server can make requests without those browser restrictions.

Not every environment variable leaks. A server-only process reading process.env.SIFTING_KEY does not expose it unless the application sends it to the client. The boundary is where the code runs and what it returns.

These are documented behaviours, not SiftingIO-specific restrictions: see the OWASP AJAX security guidance, Vite environment variable documentation and MDN's CORS guide.

If a real key has shipped in a public bundle or repository, treat it as compromised and replace it. Removing the string from the next build does not remove copies already downloaded.

The server-side request flow

The browser should ask for a quote, not choose where your server sends a secret.

  1. The browser calls GET /api/quote/EURUSD on your origin using your application's session.
  2. Your server checks the session, symbol permissions and request limit.
  3. It calls the provider using a server-side key.
  4. It returns only the fields the UI needs.

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

The three access-control hooks are required. This is a route building block, not a complete authentication system.

// quote-route.mjs
const UPSTREAM = process.env.SIFTING_BASE || "https://api.sifting.io";
const KEY = process.env.SIFTING_KEY; // server-only, never sent to the browser

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();

  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);
      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" });
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

There is no url parameter or arbitrary path passthrough. The server builds the upstream URL from a fixed base and an explicit symbol map. Otherwise, a "helpful" proxy can become an endpoint that spends your subscription on requests you never intended to support.

The route refuses redirects and stops a hung upstream call after three seconds. It does not forward upstream error bodies. An upstream 429 becomes a 503, with Retry-After passed through when supplied.

The response deliberately preserves price strings. Convert them for display calculations where appropriate, or use decimal arithmetic where precision matters. Do not mistake a string field for an invalid quote.

A hidden key is not the same as a protected endpoint

A proxy without access controls hides the key while leaving your quota open to anyone calling the proxy.

The example therefore requires:

  • Authentication: getUser validates your application's session or token.
  • Authorization: canReadQuote checks whether this user may access the requested instrument.
  • Abuse controls: allowRequest enforces limits for your application. Multiple instances normally need shared state for this.
  • HTTPS: your own session needs protection in transit too.

Wiring those hooks in looks like this. The imported modules are placeholders for your own application, so this wiring snippet will not run unchanged:

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);
Enter fullscreen mode Exit fullscreen mode

An access-hook failure returns a generic error and makes no upstream request. Crucially, access checks also run before cache hits. A previously cached quote must not let a newly unauthorized user bypass the checks.

Whether your plan permits displaying data to your own customers is a separate question. Check your agreement before launch; a proxy does not grant redistribution rights.

What the one-second cache does, and does not, solve

The cache reuses a quote after the first fetch finishes. It does not combine simultaneous cache misses: fifty requests arriving at an empty cache can still make fifty upstream calls.

It is also local to one handler instance. Add in-flight request coalescing and a shared cache if traffic requires them. This small cache is not a quota guarantee.

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

Check the failure paths, not just the happy path

For this republication, the module was checked with a stubbed fetch, a synthetic key and test access hooks. No production API requests were made. These checks exercise the route's control flow, not a real application's authentication or the live provider.

The fake upstream quote was:

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

The browser receives only s, b, a and t. The values are fixture data, not a current market quote.

Case Expected result
No session 401, no upstream call
Signed-in request for /api/quote/eurusd 200 with the trimmed quote
Repeat request inside the cache window 200 without another upstream call
Unknown symbol or arbitrary proxy path 404, no upstream call
User is not entitled, even with a cached quote 403, no upstream call
Application request limit reached 429, no upstream call
Access hook throws 500 with a generic error
Upstream 429 with Retry-After: 2 503 with the retry header
Upstream authentication error 502; upstream body is not returned
Upstream times out, sends invalid JSON or an unexpected schema 502, no upstream body returned

Keep those cases in your test suite. A route that works for one signed-in developer can still leak quota or data when permissions change.

WebSocket connections have the same boundary

Opening a provider WebSocket directly from browser code exposes any key used in the connection URL or authentication frames. If it is your application's shared private key, connect from your server and relay only permitted data to authenticated clients.

This adds work: your relay needs its own session checks, entitlement enforcement, connection limits and reconnect handling. Hiding the key is one part of the system.

For the data-delivery trade-off, the companion guide covers REST snapshots versus WebSocket streams. The SiftingIO documentation has the endpoint and response reference.

The practical rule is simple: let the browser request a capability, such as "show this allowed quote." Keep the provider credential and the decision to use it on the server.

Top comments (0)