You got a free exchange rate API key, pasted it into your React app, and it works. It also means the key is now public. Anyone who opens your site can copy it and spend your quota, and on a paid plan they spend your money. This guide shows why that happens and how to fix it with a small proxy: first on Cloudflare Workers, then as a Next.js route handler.
Why a free exchange rate API key in frontend code is public
There is no way to hide a secret in code that runs on someone else's device.
-
Browser JavaScript. The key is in your bundle, and it is in every request in the network tab. Environment variables such as
VITE_orNEXT_PUBLIC_are inlined into the bundle at build time; they are not secret. -
Query strings. AllRatesToday's
/api/rateendpoint accepts?key=for quick browser GETs. That is convenient for testing, but a key in a URL also ends up in browser history, proxy logs and referrer headers. - Mobile apps. An APK or IPA can be unpacked. Obfuscation slows an attacker down for minutes, not days.
The fix is always the same: the key lives on a server you control, and the client calls that server.
What the proxy should do
A good exchange rate proxy is small, but it does more than forward requests:
-
Add the key as
Authorization: Bearer ...on the server side. - Cache responses for 60 seconds. Rates refresh every 60 seconds upstream, so a longer-lived browser does not need fresher data, and one cached answer can serve every visitor.
- Allowlist currency codes and limit the number of targets, so the proxy cannot be used as a free general-purpose API.
- Send CORS headers only for your own origin.
- Rate limit per IP, so one script cannot drain your quota.
- Return safe errors. Never echo upstream error bodies or headers that might contain account details.
Proxy on Cloudflare Workers
Create a Worker and store the key as a secret:
npm create cloudflare@latest fx-proxy -- --type hello-world
cd fx-proxy
npx wrangler secret put ALLRATES_API_KEY
Add a rate limiting binding to wrangler.toml. The limit below is 30 requests per 60 seconds per key; period can be 10 or 60.
name = "fx-proxy"
main = "src/index.js"
compatibility_date = "2026-11-01"
[[ratelimits]]
name = "RATE_LIMITER"
namespace_id = "1001"
simple = { limit = 30, period = 60 }
Then the Worker:
// src/index.js
const ALLOWED = new Set(["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "INR", "CNY", "SGD"]);
const ORIGIN = "https://www.example.com";
const MAX_TARGETS = 10;
function corsHeaders() {
return {
"Access-Control-Allow-Origin": ORIGIN,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Vary": "Origin",
};
}
function json(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...corsHeaders() },
});
}
export default {
async fetch(request, env, ctx) {
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders() });
if (request.method !== "GET") return json({ error: "method not allowed" }, 405);
const url = new URL(request.url);
if (url.pathname !== "/rates") return json({ error: "not found" }, 404);
const source = (url.searchParams.get("source") || "USD").toUpperCase();
const targets = [...new Set((url.searchParams.get("target") || "").toUpperCase().split(",").filter(Boolean))].sort();
if (!ALLOWED.has(source) || targets.length === 0 || targets.length > MAX_TARGETS ||
!targets.every((t) => ALLOWED.has(t))) {
return json({ error: "currency not allowed" }, 400);
}
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
const { success } = await env.RATE_LIMITER.limit({ key: ip });
if (!success) return json({ error: "too many requests" }, 429);
const upstream = `https://allratestoday.com/api/v1/rates?source=${source}&target=${targets.join(",")}`;
const cacheKey = new Request(upstream); // the key is not part of the cache key
const cache = caches.default;
let cached = await cache.match(cacheKey);
if (!cached) {
const res = await fetch(upstream, { headers: { Authorization: `Bearer ${env.ALLRATES_API_KEY}` } });
if (!res.ok) return json({ error: "upstream unavailable" }, 502);
cached = new Response(await res.text(), {
headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=60" },
});
ctx.waitUntil(cache.put(cacheKey, cached.clone()));
}
const out = new Response(cached.body, cached);
for (const [k, v] of Object.entries(corsHeaders())) out.headers.set(k, v);
return out;
},
};
Deploy with npx wrangler deploy and attach the Worker to a route or custom domain, for example rates.example.com. Two details matter here:
-
Sorting the targets makes
EUR,GBPandGBP,EURthe same cache entry. -
The Cache API is per data centre and does not work on
workers.devsubdomains, so use a custom domain. Each data centre fetches at most once a minute per distinct query.
Your front end now calls the proxy, and the key stays on the server:
const res = await fetch("https://rates.example.com/rates?source=USD&target=EUR,GBP");
const rows = await res.json(); // an array, even for one pair
const eur = rows.find((r) => r.target === "EUR").rate;
Alternative: a Next.js route handler on Vercel
If your site is already a Next.js app, a route handler does the same job without a second service. Put the key in a server-only environment variable named ALLRATES_API_KEY, without the NEXT_PUBLIC_ prefix.
// app/api/rates/route.ts
const ALLOWED = new Set(["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "INR"]);
export async function GET(req: Request) {
const params = new URL(req.url).searchParams;
const source = (params.get("source") ?? "USD").toUpperCase();
const targets = [...new Set((params.get("target") ?? "").toUpperCase().split(",").filter(Boolean))].sort();
if (!ALLOWED.has(source) || targets.length === 0 || targets.length > 10 ||
!targets.every((t) => ALLOWED.has(t))) {
return Response.json({ error: "currency not allowed" }, { status: 400 });
}
const res = await fetch(
`https://allratestoday.com/api/v1/rates?source=${source}&target=${targets.join(",")}`,
{
headers: { Authorization: `Bearer ${process.env.ALLRATES_API_KEY}` },
next: { revalidate: 60 },
}
);
if (!res.ok) return Response.json({ error: "upstream unavailable" }, { status: 502 });
return Response.json(await res.json(), {
headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=30" },
});
}
Because the page and the API share an origin, you do not need CORS headers at all. next: { revalidate: 60 } keeps the upstream response in the data cache for a minute, and s-maxage=60 lets the CDN serve repeat requests without invoking the function.
For rate limiting on Vercel, use the platform's firewall rate-limit rules, or a Redis counter such as @upstash/ratelimit keyed on the client IP. Either way, the limit belongs in front of the upstream call.
CORS is not access control
It is tempting to think that Access-Control-Allow-Origin stops other people from using your proxy. It does not. CORS only tells browsers whether a page on another origin may read the response. A script running curl ignores it completely.
That is why the allowlist and the rate limit matter more than CORS. The allowlist stops your proxy from becoming a general-purpose rate service, and the rate limit caps how fast anyone, browser or not, can trigger upstream calls. Combined with the 60-second cache, the worst case is bounded: with 10 allowed targets, the number of distinct queries is finite, and each is fetched at most once a minute per data centre.
Budget the proxy, not the users
With the cache in place, your request count depends on the number of distinct queries and the cache time, not on your traffic. One base with all your currencies in one call, cached for 60 seconds in one region, is at most 1,440 calls a day, about 43,200 in 30 days. That fits the Large plan. Cache for 10 minutes and it drops to about 4,320, which fits Small at 5,000 a month. See pricing and how to cache exchange rates for more options.
If you are still deciding on a provider, the free currency API comparison covers limits and update frequency.
Rotate the key
If a key has ever been in a public bundle, a Git commit or a mobile build, treat it as leaked. Rotation takes a minute:
- Open the AllRatesToday dashboard and use the rotate button to issue a new key.
- Update the secret:
npx wrangler secret put ALLRATES_API_KEY, or the environment variable in Vercel followed by a redeploy. - Remove the old key from your code and from your Git history if it was committed.
This is the other benefit of a proxy: rotation is a server change. You never need to ship a new app build to replace a key.
FAQ
Is it safe to use a free exchange rate API key in frontend JavaScript?
No. Anything in browser code is public, including environment variables inlined at build time. Keep the key on a server or a Worker and call that from the browser.
Does CORS protect my API key or proxy?
No. CORS controls which web pages can read a response in a browser. Scripts and curl ignore it. Use a currency allowlist and per-IP rate limiting to protect the proxy.
How long should the proxy cache exchange rates?
60 seconds matches how often the rates refresh. If you need fewer upstream calls, cache for several minutes; most price displays do not need second-by-second data.
What should I do if my key was exposed?
Rotate it from the AllRatesToday dashboard, update the secret in your Worker or hosting environment, and remove the old key from your code and Git history.
Can I use the proxy for a mobile app?
Yes. The same proxy works for iOS and Android. The app calls your proxy URL and never contains the key.
Originally published at allratestoday.com.
Top comments (0)