Rate Limiting, CORS & Secrets Management

intermediate rate-limiting cors secrets token-bucket preflight

Three practical topics that show up in backend and system-design interviews. They’re unrelated but all live under “keeping our API safe and well-behaved”. Let’s take them one at a time.

Rate limiting — capping how often someone hits us

Rate limiting caps how many requests a client can make in a window of time. Why we do it — to stop abuse (brute-force login, scraping), protect against traffic spikes, and share capacity fairly. It’s the difference between one bad actor taking us down and one bad actor just getting throttled.

There are four algorithms interviewers ask about. The only real differences are how they count and how bursty they allow.

  • Fixed window — count requests per fixed slot (e.g. 100 per minute). Simple, but has a nasty edge: someone can send 100 at 0:59 and 100 at 1:00 — 200 requests in two seconds — because the counter resets at the boundary.
  • Sliding window — instead of a hard reset, the window slides with time, so it smooths out that boundary burst. More accurate, a bit more bookkeeping.
  • Token bucket — a bucket refills with tokens at a steady rate; each request spends one. If the bucket has tokens, we allow a burst; when it’s empty, we reject. Great for “steady rate but tolerate short spikes”.
  • Leaky bucket — think of requests dripping out of a bucket at a fixed rate (a queue). It smooths traffic into a constant outflow — no bursts allowed, unlike the token bucket. That’s the one difference to remember between the two buckets.

When we do throttle someone, we respond with HTTP 429 Too Many Requests, and politely tell them when to come back with a Retry-After header.

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Remaining: 0

Token bucket, visualized

Token Bucket
refill +1 token every 100ms, up to a max of 10
bucket: [ ● ● ● ● ● ○ ○ ○ ○ ○ ]   5 / 10
↓ request arrives
✓ token available → spend 1, allow (200)
✗ bucket empty → reject (429)

The tokens sitting in the bucket are what allow a burst — save up 10 tokens while idle, then fire 10 requests at once. That’s the token bucket’s whole personality.

CORS — the browser’s cross-origin gatekeeper

First, the thing CORS is built on: the same-origin policy. By default, a browser blocks JavaScript on site-a.com from reading responses from site-b.com. An origin is the triple of scheme + host + port — change any one and it’s a different origin. This stops a malicious page from quietly reading our logged-in bank data.

CORS (Cross-Origin Resource Sharing) is the controlled relaxation of that rule. It’s how a server says “actually, I allow site-a.com to call me”. In simple language — CORS isn’t a lock the server puts on itself; it’s the server handing the browser a permission slip.

Key point people miss: CORS is enforced by the browser, for browser JS. Server-to-server calls, curl, Postman — none of them care about CORS. So it’s not a server-side security control; it’s a browser safety feature.

The server grants access with response headers:

Access-Control-Allow-Origin: https://site-a.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Authorization, Content-Type

Preflight — the browser asks first

For “non-simple” requests (a PUT/DELETE, or a custom header like Authorization), the browser sends a preflight — an automatic OPTIONS request that asks “am I allowed to do this?” before sending the real one. If the server’s response headers say yes, the real request goes; if not, the browser blocks it and we see that classic CORS error in the console.

OPTIONS /api/orders HTTP/1.1
Origin: https://site-a.com
Access-Control-Request-Method: DELETE

The fix for a CORS error is always server-side — configure the allowed origins/methods/headers. We can’t fix it from the frontend, and slapping Access-Control-Allow-Origin: * on everything is a lazy, unsafe reflex.

Secrets management — keys, tokens, DB passwords

Secrets are the credentials our app needs but must never expose — DB passwords, API keys, signing keys. The whole game is keeping them out of places that leak.

  • Never commit secrets to git. Once pushed, they’re in history forever (and scraped by bots within minutes). Use a .gitignore’d .env file locally and commit a .env.example with blank values instead.
  • Environment variables are the baseline — the classic 12-factor approach. Config comes from the environment, not code, so the same image runs in dev and prod with different secrets injected.
  • Secret managers (AWS Secrets Manager, HashiCorp Vault, Doppler) are the step up. Env vars are fine but they’re plaintext, visible to anything in the process, and awkward to rotate. Secret managers add encryption at rest, access control (who/what can read which secret), and audit logs.
  • Rotation — secrets should be rotated (changed) on a schedule and immediately if one leaks. This is exactly where env vars hurt: rotating means a redeploy, while a secret manager can rotate and distribute the new value without touching the app.
# Local: keep real values in .env (gitignored), commit only the template
echo ".env" >> .gitignore
# .env.example (committed) shows the shape, not the secrets:
#   DATABASE_URL=
#   JWT_SECRET=

The one-line rule: secrets live in the environment or a manager, never in the code or the repo, and we should be able to rotate any of them without a scramble.

Interview soundbite

Rate limiting caps request frequency — fixed window (simple, boundary-burst bug), sliding window (smoother), token bucket (allows bursts via saved tokens), leaky bucket (constant drain, no bursts); throttle with 429 + Retry-After. CORS relaxes the browser’s same-origin policy so a server can allow specific cross-origin JS; browser-enforced only, “non-simple” requests trigger an OPTIONS preflight, and it’s always fixed server-side. Secrets go in env vars or a secret manager — never in git — with rotation, access control, and a committed .env.example template.