Security Audit Report

ExchangeRate-API Security Audit: 4 Findings on the Free Open Tier

https://open.er-api.com/v6/latest/USD
88 B Good security posture
4 findings
1 Critical 2 High 1 Low
Share
01

About This API

ExchangeRate-API is a commercial currency-conversion service operated since 2010. It ships two products that live on different hosts and behave differently:

  • The free open tier at open.er-api.com. No API key, no signup, returns USD rates against ~166 currencies, refreshed once every 24 hours. Cloudflare-fronted with a one-hour edge cache. This is the endpoint we scanned.
  • The commercial paid tier at v6.exchangerate-api.com. Requires an API key in the URL path, ships hourly refresh on the entry tier and minute-level refresh on the higher plans, and offers historical, conversion, and pair endpoints.

The free tier is the one developers reach for first. It's the canonical 'fetch the dollar exchange rate' endpoint in tutorials, in personal-finance side projects, in the 'show me USD-to-EUR right now' microservices that get spun up for hackathons and demo apps. The response shape is small and stable: a top-level result: "success", provenance fields (provider, documentation, terms_of_use), three pairs of last-update / next-update timestamps, an EOL marker, a base code (always USD on this endpoint), and a flat rates object with currency-code keys and numeric values.

This audit is the latest in middleBrick's public-API case-study series. We scanned https://open.er-api.com/v6/latest/USD with no API key and no special headers. The scan returned four findings; none of them are exploitable on the open tier in isolation. The piece that's worth paying attention to is the consumer side: the apps that pull rates from this free feed and surface them inside something a user is about to spend money on.

02

Threat Model

The threat model for the open free tier is narrow. The endpoint returns the same data to every caller, has no concept of accounts or sessions, and holds no PII. An attacker compromising the API surface itself yields nothing; the data is already public.

Real risk lives downstream

The interesting threat surface is the consumer — the apps and services that pull from open.er-api.com and use the values in a financial flow. Three patterns matter.

Pattern 1: stale-rate exposure. The free tier refreshes once every 24 hours, and Cloudflare caches each response for an hour at the edge. A consumer that fetches at T+0 minutes and a consumer that fetches at T+59 minutes can see the same rate even if the underlying source moved. For a 'show me a number' tutorial app, this is fine. For an app that quotes a price in one currency and accepts payment in another, a 24-hour-stale rate is a real exposure window — currency markets routinely move 1-3% intraday, and pegged-or-volatile pairs (ARS, TRY, LBP, NGN) can move much more. The free tier's TTL is a feature for the maintainer and a downstream risk for the consumer who didn't read the docs.

Pattern 2: no integrity guarantees on a numeric payload. The response is plain JSON over HTTPS. There's no signature, no checksum, no per-rate provenance. A consumer trusts the connection-level TLS and trusts that the upstream provider got the rates right. For a consumer-grade currency widget, that's a reasonable trust chain. For a regulated fintech that needs an audit trail (CNBV in Mexico, BCRA in Argentina, SBS in Peru), 'we trusted exchangerate-api.com' is not the documentation a regulator wants to see.

Pattern 3: silent fallback in 'currency conversion' libraries. A common pattern is to wrap several free FX feeds in a try/catch chain — try open.er-api.com, fall back to frankfurter.app, fall back to a local cache. If the primary endpoint returns a slightly different rate than the fallback (different upstream sources, different rounding, different update cadences), the wrapper silently chooses one and the user sees a number that depends on which feed was up that minute. This isn't an attack; it's a class of bug that the architecture invites.

What's not in the threat model

No PII, no auth, no state, no operator surface, no embedded URLs to chase. The provenance fields (provider, documentation, terms_of_use) are static strings the maintainer publishes intentionally. There's no IDOR finding because the endpoint structure (one URL, one base currency) doesn't enumerate.

03

Methodology

middleBrick ran a black-box scan against https://open.er-api.com/v6/latest/USD. No API key, no Origin header, no destructive methods. Twelve security checks executed; four produced findings, eight returned clean negatives.

Manual confirmation alongside the scan:

  • HEAD /v6/latest/USD → 200 with the same headers as GET. No method asymmetry.
  • OPTIONS /v6/latest/USD → 405 Method Not Allowed, allow: GET. The endpoint is GET-only and explicit about it.
  • GET with an Origin: https://malicious.example header → 200 with access-control-allow-origin: *. The wildcard is verbatim, not request-echoed.
  • Response headers include cache-control: public, max-age=3600, x-content-type-options: NOSNIFF, x-frame-options: SAMEORIGIN, server: cloudflare, and a cf-cache-status indicator. No strict-transport-security.

The four findings:

  • CRITICAL: API accessible without authentication (CWE-306) — true and intentional for an open tier.
  • HIGH: CORS allows all origins (wildcard *) (CWE-942) — true and intentional for an endpoint designed to be called from any browser app.
  • HIGH: Missing rate-limiting headers (CWE-770) — true; the upstream documents a per-IP soft cap but doesn't expose budget headers.
  • LOW: Missing security headers — HSTS specifically (CWE-693). X-Content-Type-Options and X-Frame-Options are present.

Eight checks returned clean: BOLA, BFLA, property authorization, data exposure, encryption (TLS is properly configured), inventory management, unsafe consumption (no embedded external URLs to follow), and SSRF. The Web3 / DeFi / LLM categories are not applicable to a REST FX feed and were not relevant.

04

Results Overview

ExchangeRate-API's open tier received a B grade with a score of 88. Four findings: one CRITICAL, two HIGH, zero MEDIUM, one LOW.

The CRITICAL is 'API accessible without authentication.' This is intentional — the entire purpose of the free tier is to be reachable without a key. The scanner correctly flags that any caller can fetch the resource; on this product line, that's the design. The finding stays critical because on APIs where authentication should be required, suppressing the severity would lose the signal. The right way to read it is: 'this is the open tier; auth is intentionally absent; on the commercial tier, auth is required and lives at a different host.'

The two HIGH findings:

  • Wildcard CORS. access-control-allow-origin: * on every response. The endpoint is designed to be called from any browser app. The wildcard is necessary for the use case; it would be wrong on an authenticated endpoint that ships credentials.
  • Missing rate-limit headers. The upstream documents a soft per-IP limit on the free tier (we observed no specific number; the docs reference 'fair use'). The 429 path triggers without warning headers.

The LOW is 'missing security headers (1/4).' The endpoint emits X-Content-Type-Options: NOSNIFF and X-Frame-Options: SAMEORIGIN but does not emit Strict-Transport-Security. Two of the four hardening headers we look for are present; HSTS is the one that's missing. (The fourth, Cache-Control, is present at public, max-age=3600.)

For comparison, our previous public-API case studies in this series produced these scores:

  • JSONPlaceholder: 73 (C, 11 findings)
  • ReqRes: 73 (C, 17 findings)
  • FakeStoreAPI: 75 (C, 10 findings)
  • DummyJSON: 75 (B, 13 findings)
  • PokéAPI: 76 (B, 12 findings)
  • Random User: 79 (B, 12 findings)
  • HTTPBin: 82 (B, 11 findings)
  • ExchangeRate-API (open tier): 88 (B, 4 findings)
  • SWAPI: 91 (A, 4 findings)

ExchangeRate-API's open tier sits second-cleanest in the cohort. The four-finding count is at the SWAPI tier; the score is three points lower because the CRITICAL no-auth finding weights the authentication category to 45/100 vs. SWAPI's structural BOLA which lives in a category that already has a high baseline.

05

Detailed Findings

Critical Issues 1
CRITICAL CWE-306

API accessible without authentication

The endpoint returned 200 without any authentication credentials.

Remediation

Implement authentication (API key, OAuth 2.0, or JWT) for all API endpoints.

authentication
High Severity 2
HIGH CWE-942

CORS allows all origins (wildcard *)

Access-Control-Allow-Origin is set to *, allowing any website to make requests.

Remediation

Restrict CORS to specific trusted origins. Avoid wildcard in production.

inputValidation
HIGH CWE-770

Missing rate limiting headers

Response contains no X-RateLimit-* or Retry-After headers. Without rate limiting, the API is vulnerable to resource exhaustion attacks (DoS, brute force, abuse).

Remediation

Implement rate limiting (token bucket, sliding window) and return X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers.

resourceConsumption
Low Severity 1
LOW CWE-693

Missing security headers (1/4)

Missing: HSTS — protocol downgrade attacks.

Remediation

Add the following headers to all API responses: strict-transport-security.

authentication
06

Attacker Perspective

An attacker has no productive direct work on the open tier. The data is the same value the API would happily hand to anybody, the response can be cached locally, and there are no accounts to attack. The interesting attacker reads the docs, identifies which fintech-adjacent apps consume the open feed, and looks at the consumer side.

Find the consumer, race the cache

The most productive 'attack' is to find a small fintech (a payments widget, a remittance estimator, a price-display in a checkout flow) that pulls from open.er-api.com, observe the cache TTL (one hour at Cloudflare's edge, 24 hours at the upstream source), and time a real-money flow against the freshest possible refresh. If the consumer quotes a USD-to-MXN rate at T+0 and accepts the payment at T+90 minutes (cache expired but consumer cached for an hour internally), the rate the customer paid against and the rate the merchant settles at can diverge. The divergence is small but real, and on a fintech doing thousands of conversions per day it's an arbitrage. The fix is on the consumer side: don't quote prices off a 24-hour-stale public feed.

Substitute the source

An attacker who already has a foothold (DNS poisoning, compromised npm dependency, MITM on a non-HSTS page) can substitute the response. The open tier doesn't ship HSTS, which means the first request from a new client is plain HTTP-eligible if the client doesn't enforce HTTPS itself. Most browsers do; most server-side HTTP clients do too if configured correctly; but the attack window exists for misconfigured consumers. A consumer that has TLS pinning, HSTS preload, or — better — a server-side fetch with a hardcoded HTTPS URL in the source closes this entirely.

What's not productive

Attacking the open tier itself yields nothing. There's no operator surface, no auth bypass to attempt, no IDOR to walk, no injectable parameter (the URL path is /v6/latest/USD and the path component USD is the base currency — anything else returns an error). The Cloudflare front end absorbs DoS attempts. The data is already free.

07

Analysis

The findings break down cleanly. None of them require a remediation on the open tier itself; all of them inform how a consumer should treat the feed.

1. No authentication on the open tier. The CRITICAL finding is the design.

$ curl https://open.er-api.com/v6/latest/USD
HTTP/2 200
content-type: application/json

{"result":"success","provider":"https://www.exchangerate-api.com",...,"base_code":"USD","rates":{"USD":1,"AED":3.6725,"AFN":63.485851,...}}

The endpoint exists to be called without a key. The commercial tier at v6.exchangerate-api.com requires an API key in the path (e.g. /v6/<KEY>/latest/USD) and ships its own auth model, hourly-or-better refresh, and historical endpoints.

2. Wildcard CORS.

access-control-allow-origin: *

Verbatim wildcard, on every response, regardless of the request Origin header. We confirmed this with a request carrying Origin: https://malicious.example — the response is identical to a same-origin request. The wildcard is necessary for the open tier's use case (browser apps from any origin) and harmless because the response carries no credentials.

3. No rate-limit headers. The response carries cache-control: public, max-age=3600 (Cloudflare edge cache directive), cf-cache-status: HIT on cached responses, but no X-RateLimit-* or Retry-After. The provider's docs describe a 'fair use' limit on the free tier without specifying a number. Consumers exceeding the limit get 429 with no header to tell them when to retry. The fix is in the consumer's own backoff strategy.

4. Missing HSTS. Of the four hardening headers we check, three are present (X-Content-Type-Options: NOSNIFF, X-Frame-Options: SAMEORIGIN, Cache-Control: public, max-age=3600) and one is missing (Strict-Transport-Security). On a Cloudflare-fronted endpoint, HSTS is one of the easier headers in the world to add — it's a single Page Rule or Worker-level header. The absence is a hygiene gap, not an exposure.

What didn't fire (and why it matters). The scanner did not flag CSRF, server-side request forgery, mass assignment, broken object-level authorization, or unsafe API consumption. None of these apply: the endpoint is GET-only, returns no embedded URLs that the scanner could chase, and has no concept of authenticated state. The clean negatives are real and they're what holds the score up at 88.

08

Industry Context

The free FX-feed niche is small and well-defined. The peers are frankfurter.app (ECB-based, no auth, daily refresh), exchangerate.host (formerly free, now requires signup), fxratesapi.com (free tier with key), and the various ECB / IMF / BIS public datasets. Among that peer group, ExchangeRate-API's open tier is the most popular for tutorial-and-side-project use because it's truly keyless and ships a wide currency list (~166 codes including LATAM and African currencies that the ECB feed lacks).

For fintech and payments compliance: the open tier is fine for display and education. It is not appropriate as the rate source for a flow under PCI-DSS, SOX, or any LATAM equivalent (CNBV Mexico, SBS Peru, Banrep Colombia). Regulated fintechs need a signed contract with the FX provider, an SLA, an audit trail, and typically a sub-second-to-minute refresh rather than 24-hour. The commercial tier of ExchangeRate-API addresses some of this; the open tier does not and isn't trying to.

OWASP API Top 10 mapping for the findings here: API1 (broken authentication, intentional), API8 (security misconfiguration — wildcard CORS, missing HSTS), API4 (unrestricted resource consumption — no rate-limit headers, though Cloudflare absorbs the actual abuse). API3 (BOLA), API5 (BFLA), API6 (unrestricted access to sensitive flows), API9 (improper inventory) and API10 (unsafe consumption) are not represented because the surface is too narrow to produce them.

For consumers: if you're showing a rate to a user, the open tier is appropriate. If you're charging the user against the rate, you need either the commercial tier of ExchangeRate-API or a peer provider with a contract behind it.

09

Remediation Guide

Missing rate-limit headers

Add X-RateLimit-* and Retry-After at the Cloudflare Worker layer on the response path. The upstream rate-limit logic doesn't need to change; the Worker reads the per-IP counter from KV (or Durable Object) and writes the headers back.

// Cloudflare Worker on the response path
export default {
  async fetch(req, env) {
    const ip = req.headers.get('cf-connecting-ip');
    const key = `rl:${ip}`;
    const count = parseInt((await env.RL.get(key)) ?? '0');
    const limit = 1500;
    const remaining = Math.max(0, limit - count);
    await env.RL.put(key, String(count + 1), { expirationTtl: 3600 });
    const upstream = await fetch('https://upstream/v6/latest/USD');
    const r = new Response(upstream.body, upstream);
    r.headers.set('X-RateLimit-Limit', String(limit));
    r.headers.set('X-RateLimit-Remaining', String(remaining));
    if (remaining === 0) r.headers.set('Retry-After', '3600');
    return r;
  }
};

Missing HSTS header

Cloudflare dashboard → SSL/TLS → Edge Certificates → enable HSTS with includeSubDomains and preload. One toggle.

# Equivalent header at any reverse proxy:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

Consumer-side: don't quote prices off a 24-hour-stale rate

Surface time_last_update_utc next to any rate you display. For charging flows, switch to the commercial tier or a peer with a refresh cadence shorter than your settlement window.

// React component showing the rate AND its freshness
async function fetchRate() {
  const r = await fetch('https://open.er-api.com/v6/latest/USD');
  const j = await r.json();
  return {
    usdToEur: j.rates.EUR,
    asOf: j.time_last_update_utc,
    nextUpdate: j.time_next_update_utc,
    stale: (Date.now() / 1000) > j.time_next_update_unix
  };
}
// In the UI, render `1 USD = ${rate} EUR — as of ${asOf}` and gate any 'buy' button on stale === false.

Consumer-side: cache server-side, not in the browser

Fetch server-side, cache in your own KV / Redis with a TTL shorter than the upstream's, and serve to clients from your cache. Insulates you from the upstream's TTL drifting under your assumptions and reduces your exposure to the upstream's 429 path.

// Cloudflare Worker as the consumer-side cache
export default {
  async fetch(req, env) {
    const cached = await env.RATES.get('usd', 'json');
    if (cached && cached.fetched_at > Date.now() - 30 * 60_000) {
      return Response.json(cached);
    }
    const upstream = await fetch('https://open.er-api.com/v6/latest/USD');
    const data = await upstream.json();
    const payload = { ...data, fetched_at: Date.now() };
    await env.RATES.put('usd', JSON.stringify(payload), { expirationTtl: 3600 });
    return Response.json(payload);
  }
};

Consumer-side: explicit primary + circuit-breaker fallback

If you wrap multiple FX feeds, pick a primary, log every fallback event, and treat the fallback as a circuit breaker rather than a silent substitute. Two feeds disagreeing by 0.1-1% is normal; silently switching between them inside a payment flow is a bug.

async function getRate(base, quote) {
  try {
    const j = await (await fetch(`https://open.er-api.com/v6/latest/${base}`)).json();
    return { rate: j.rates[quote], source: 'erapi', asOf: j.time_last_update_unix };
  } catch (e) {
    metrics.increment('fx.fallback_triggered', { from: 'erapi', to: 'frankfurter' });
    const j = await (await fetch(`https://api.frankfurter.app/latest?from=${base}&to=${quote}`)).json();
    return { rate: j.rates[quote], source: 'frankfurter', asOf: j.date };
  }
}
10

Defense in Depth

For the maintainer, the action items are short and almost all optional.

1. Add HSTS. Cloudflare ships a one-click HSTS toggle. Strict-Transport-Security: max-age=31536000; includeSubDomains; preload takes the LOW finding to zero and tightens the consumer-side TLS posture for free.

2. Emit rate-limit headers. Even on a 'fair use' policy, surfacing X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After closes the HIGH finding and gives consumers a budget signal so they can back off pre-emptively. A Cloudflare Worker can add these on the response path without changing the upstream.

3. Document the open / commercial gap. The provider already does this in their docs, but a one-paragraph 'when to use the open tier vs. when to use the paid tier' note inline at the open endpoint's docs would be high-leverage. The current docs lean toward 'sign up for the paid tier'; an explicit 'use the open tier for X, switch to paid for Y' would help consumers self-select correctly and reduce the number of fintechs accidentally building on the wrong tier.

For consumers — the apps that pull from the open tier — the defenses are operational rather than the provider's responsibility:

  • Don't quote a price off a 24-hour-stale rate. If you're surfacing a rate to a user, surface the time_last_update_utc alongside the number so the user knows what they're reading. If you're charging against the rate, fetch from a tier that refreshes faster than your settlement window.
  • Cache server-side, not in the browser. Pulling from the open tier directly in the user's browser is fine for a tutorial. For a production app, fetch server-side, cache locally with a TTL shorter than the upstream's TTL, and serve from your cache. This protects you from the upstream's cache TTL drifting under your assumptions.
  • Have a fallback that's an actual fallback. If your wrapper falls back from open.er-api.com to a peer, the rates from the two sources can disagree by 0.1-1% in normal conditions and more during volatility. Pick a primary, document it, and use the fallback as a circuit breaker rather than a silent substitute.
  • Don't use the open tier in a regulated flow. For PCI-DSS and the LATAM regulator equivalents, the open tier doesn't ship the SLA, audit trail, or contractual posture that a regulator expects. Use the paid tier or a peer with a contract.
11

Conclusion

ExchangeRate-API's open free tier scored 88/100 with four findings and a B grade. Nothing here is exploitable in isolation. The CRITICAL finding (no auth) is the entire purpose of the open tier; the two HIGH findings (wildcard CORS, no rate-limit headers) are structural to a public, edge-cached read-only endpoint; the LOW (missing HSTS) is a one-line fix at the Cloudflare layer.

The interesting analysis isn't whether the open tier is safe — it is, for the use case it serves. The interesting analysis is the consumer side: a fintech-adjacent app pulling rates from a 24-hour-refresh public feed inherits a different risk profile than the same app pulling from the commercial tier with hourly-or-better refresh and a contract. The two products live at different hosts (open.er-api.com vs. v6.exchangerate-api.com), and that domain split is doing real work — it lets the provider keep the open tier truly open while reserving the operational guarantees, the SLA, and the auth surface for the paid product. Consumers who treat the two as interchangeable are making a category error.

For maintainers of similar public-data services: the response-shape discipline here is worth studying. The payload is exactly what consumers need (a rates object plus provenance and timestamps) and nothing else. No internal IDs, no metadata fields, no embedded URLs to chase. That discipline is what kept the finding count at four.

For consumers: cache server-side, surface the time_last_update_utc next to any rate you display, don't put the open tier behind a payment screen, and switch to the commercial tier (or a peer with a contract) the moment your flow is regulated.

Frequently Asked Questions

Is ExchangeRate-API's free open tier safe to use in production?
Yes, for non-regulated, display-or-tutorial use. The endpoint is stable, Cloudflare-fronted, returns the same data to every caller, and holds no PII. The four findings the scanner produced are all structural to an open public feed — no auth, wildcard CORS, no rate-limit headers, and one missing security header. None of them are exploitable. The question that matters isn't safety of the feed; it's whether the consumer-side patterns around it are appropriate for what the consumer is doing.
What's the difference between open.er-api.com and the commercial paid tier?
The two products live on different hosts and have different security postures. The open tier (open.er-api.com) is keyless, refreshes once every 24 hours, returns USD-base rates only on the public endpoint we scanned, and is fronted by Cloudflare with a one-hour edge cache. The commercial tier (v6.exchangerate-api.com) requires an API key in the URL path, refreshes hourly on the entry plan and faster on higher plans, exposes historical / pair / conversion endpoints, and ships a contract with an SLA. Consumer apps that need any of: a contract, a fast refresh cadence, historical data, or any base currency other than USD on the public path, should be on the paid tier or a peer.
Can I use the open tier in a regulated fintech flow?
No. PCI-DSS, SOX, CNBV (Mexico), SBS (Peru), Banrep (Colombia), and equivalent regulators expect a contracted FX source with an SLA, an audit trail, and typically sub-minute refresh. The open tier doesn't ship any of those — by design, because it's free. For a regulated flow, the commercial tier of ExchangeRate-API or a peer provider with a contract is the correct choice.
Why is missing authentication a CRITICAL finding when the open tier is supposed to be keyless?
Because suppressing the severity for any specific API would mean missing it on all the APIs where missing auth is genuinely a bug. The scanner is consistent: any 200 response with no credentials gets the CRITICAL flag. The right reading on this product is 'open tier by design, paid tier on a separate host requires a key' — the scanner's signal is correct, and the contextual interpretation is the maintainer's documentation, not a scanner rule.
How stale can the rate get?
The upstream refreshes once every 24 hours; the Cloudflare edge caches each response for one hour. So in the worst case a consumer can see a value that was generated up to 25 hours ago. The response includes time_last_update_utc and time_next_update_utc — surface them in your UI alongside any rate you display. For volatile pairs (ARS, TRY, LBP, NGN, and the various LATAM currencies during pegging events), 24-hour staleness is enough to misprice a transaction by several percent.