CORS Is Not Security (and Your Rate Limiter Probably Isn't Global)
CORS only tells browsers what they may read, and the built-in rate limiter counts per instance. Both work in the demo and leak in production once you forget which assumption you were making.
Authentication tells you who is calling. It doesn’t stop a signed-in user, or a service with an API key, from creating ten thousand records in a minute. And it says nothing about what a browser should do with your responses.
Rate limiting, security headers, and CORS close those gaps. Each one also comes with a misconception attached.
CORS is not security
CORS only tells browsers whether a page on another site may read your responses. curl, Postman, and other servers ignore it entirely.
It protects your users’ browsers from being used against your API. It is not access control. If a CORS policy is the thing standing between an endpoint and the public, nothing is standing there. Authentication and authorization still do that job.
Your rate limiter is per instance
The built-in ASP.NET Core rate limiter keeps its counters in memory, per instance. Run three instances behind a load balancer and each user gets three times the limit.
For a real fleet, enforce limits where all traffic passes: an API gateway (Azure API Management, for example) or a limiter backed by a shared store like Redis. The in-app limiter still earns its place as a second line of defense. It just isn’t the first.
Fixed window is the simplest algorithm and the one everyone reaches for. ASP.NET Core also ships sliding window (smoother at the window edges), token bucket (allows short bursts), and concurrency (limits requests in flight, not per second). Pick by what you’re protecting, not by which one the sample used.
API keys in production
A key is a password for a program. Treat it like one.
- Store hashes, not keys. If your config leaks, hashed keys are useless to an attacker. Compare in constant time.
- Allow more than one key per client, so you can rotate: issue the new key, let the client switch, revoke the old one.
- Never accept keys in the query string. URLs end up in logs, browser history, and referrer headers.
- Know when to graduate. Keys identify a client but can’t express scopes, expiry, or delegation. For anything serious, use the OAuth 2.0 client credentials flow and a short-lived token.
Headers worth knowing about
Strict-Transport-Security(HSTS) tells browsers to always use HTTPS. Add it in production withapp.UseHsts()once you’re sure HTTPS works everywhere. Not before.X-Frame-Options: DENYis the older form offrame-ancestors 'none'. Some teams send both for old browsers.
The point
Every one of these works in the demo and leaks in production. CORS gets mistaken for access control, an in-memory limiter gets mistaken for a global one, and keys get stored like config values. None of them are hard to fix once you know which assumption you were making.