Your key is hidden. Who is running up the bill? · slopware 101

slopware 101 · step 21 of 35

Your key is hidden. Who is running up the bill?

by · 4 min read

A privileged API key authorizes calls against an account, which is typically the account billed for them. Keeping it secret prevents other people from using it directly. The earlier rule was to keep that key on your server. The browser calls a route of yours without a key, your server adds the key, calls the provider and returns only the answer. That settles who can hold the key. It does not settle how often your route can be made to use it.

Rate limiting is a count of how many requests one caller may make in a window. An endpoint without it is a tap left open with the meter turning.

Two choices: scope and window

Before anything can be counted, the caller needs a name. That name is the scope of the limit, and you have several to choose from:

  • per account, for logged-in users;
  • per IP address, a network address that may represent several people, for anonymous traffic;
  • per API key, if you hand keys to other developers;
  • global, one count for the whole route.

Each scope has a blind spot. Many people behind one office or mobile network share an IP, so a per-IP limit can lock out the neighbours. One person with many accounts gets many per-account budgets. A global cap protects your bill, but it lets one caller use it all up. Real systems layer several scopes.

The window is how long a count lasts before it resets: a minute, an hour, a day. A fixed window resets on the clock, which lets someone spend a full budget at 12:00:59 and another full budget at 12:01:00. A sliding window always looks back over the last sixty seconds, which smooths that edge at the cost of a little more bookkeeping.

For a hypothetical photo service, suppose we choose 3 analyses a minute and 15 a day per IP, plus 300 model calls a day across the service. These are example limits, not recommended defaults. The short window constrains bursts, the daily window constrains repeated use, and the global count constrains total calls. The invoice still depends on the cost of each call and any other billed work.

Quota is not concurrency

A quota counts requests over time. Concurrency is how many requests are in flight at the same moment. The two fail differently. Suppose a route allows 10 requests a minute per user, and each request waits 30 seconds on a model. If ten users each send their 10 at once, a hundred slow calls run together, all within quota, all holding connections and memory. A concurrency limit, a cap on simultaneous work per user or per route, is a separate setting. Stop here and check which of the two your platform's defaults actually limit.

CORS is not a lock

CORS is the rule that decides which sites may read your API's responses from a browser. Rules are written per origin, which is the scheme, domain and port of a page. Your server tells the browser which origins are allowed, and the browser enforces it. That protects visitors from a hostile page reading your API through their browser. A wildcard permits non-credentialed cross-origin reads. Credentialed browser requests require an explicit allowed origin and the relevant credentials settings. Allowing an origin does not grant a user permission to read a row, and a CORS policy does not replace protection against cross-site request forgery.

A CORS error is that rule refusing a call. The error is printed by the browser, and it says nothing about who is allowed in. A request sent from a terminal never meets CORS at all, because curl is not a browser and never asks. Authentication and rate limits are what stop direct requests. CORS is not authentication.

The hundred-request check

Use a local or staging copy of your app. Point it at a route that does not spend real money, with the provider call stubbed out, and run:

for i in {1..100}; do curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/api/summarize; done | sort | uniq -c

This Bash/zsh loop sends a hundred GET requests one after another and counts the status codes. Use a harmless local test route configured to return 200 before adding the limit; adapt the method if the actual route expects POST. Authentication failures or a wrong method do not test the limit. Add the limit and run the loop again. The requests over the limit should now come back as 429, the code for too many requests.

Be careful about what that proves. It proves that this run, from this IP, sending requests in sequence, was limited. It does not prove the limit holds for parallel requests. It does not prove it holds across your server instances if the counter lives in each instance's memory, or against a caller who rotates IPs. If your app runs on several instances, the counter has to live somewhere they all share. Each of those cases is its own test.