Skip to content
Suriyaprakash.
Engineering

Field note / Engineering+

API Security in Depth: Rate Limiting, CORS, CSRF, Input Validation, and Security Headers

A practical guide to API security: rate limiting and 429, what CORS does and does not protect, CSRF defenses, input validation, and security headers.

Published
Reading time24 min read
Filed underEngineering

Part 5 built a permission system: given an authenticated user, one place decides what they may do to which record. That decision only helps if the request reaches it in a sane form, and if the surrounding API does not give an attacker a way around it.

This part is about that surrounding layer: the controls that sit between the open internet and your handlers. They are less glamorous than authentication and authorization, and they account for a lot of real incidents, because they are easy to configure almost-correctly. A CORS header that looks right and protects nothing. A CSRF defense that covers POST but not the endpoint someone added last month. A rate limit keyed on a header the caller controls.

Throughout, I separate three things: what a standard says, what I tested myself, and what is a recommendation. The CORS section includes a two-origin experiment I ran in a real browser, because the most common misconception about CORS is easy to settle that way.

Quick answer

  • Rate limiting protects availability and cost. Limit by the identity you trust, return 429, and put hard caps on body size, page size and expensive operations too.
  • CORS is a browser rule about reading responses. It is not access control. A blocked cross-origin request can still have reached your server and run.
  • CSRF matters when the browser attaches credentials automatically (cookies, mostly). Defend with tokens or request-origin checks, and treat SameSite as an extra layer, not the whole defense.
  • Validate input on the server, against an allow-list, at the edge. Pass the validated output onward, never the raw body.
  • Security headers are cheap hardening. For a JSON API the useful set is small.
  • Errors are part of the API surface. Be consistent, be generic about internals, and use the right status code.
internet
   |
   v
[ TLS + headers ] -> [ rate limit ] -> [ CORS / CSRF ] -> [ parse + validate ]
                                                                  |
                                                                  v
                                          [ authenticate ] -> [ authorize (Part 5) ] -> handler
                                                                  |
                                                                  v
                                                 [ generic, consistent error responses ]

Where this fits in the OWASP API Top 10

The OWASP API Security Top 10 (2023 edition) is a useful map of what goes wrong. Its list, in order, is: Broken Object Level Authorization, Broken Authentication, Broken Object Property Level Authorization, Unrestricted Resource Consumption, Broken Function Level Authorization, Unrestricted Access to Sensitive Business Flows, Server Side Request Forgery, Security Misconfiguration, Improper Inventory Management, and Unsafe Consumption of APIs.

You have already covered several of these in this series: authentication (Parts 2 to 4) and the three authorization entries (Part 5). This post addresses most of the rest:

OWASP itemCovered here by
Unrestricted Resource ConsumptionRate limits, body and page-size caps, timeouts
Security MisconfigurationCORS, headers, verbose errors
Unrestricted Access to Sensitive Business FlowsPer-flow limits (signup, reset, checkout)
Server Side Request ForgeryAllow-listing outbound destinations (short note below)
Improper Inventory ManagementKnowing which endpoints and versions exist

Rate limiting and resource consumption

A rate limit answers "how much may one caller do in a period?" Without one, a single client can exhaust your database connections, your third-party API quota, or your bill. Several different attacks reduce to the same weakness: credential guessing (Part 4), scraping, enumeration, and plain denial of service.

What to limit, and by what key

The key decides whether the limit protects you or can be sidestepped:

  • Per IP address is the baseline for anonymous endpoints, but many users share one IP (offices, mobile carriers) and one attacker can use many.
  • Per authenticated user or API key is more accurate once you know who is calling.
  • Per account and IP together suits sensitive flows such as sign-in, as Part 4 showed: slow guessing at one account and slow one address sweeping many accounts.
  • Per sensitive flow matters on its own. A limit on "password reset emails per account per hour" protects the mailbox and your sending reputation, regardless of general traffic.

One trap deserves a callout: the client IP behind a proxy. Proxies and CDNs pass the original address in a header such as X-Forwarded-For, and a caller can send that header themselves. Only trust forwarded-address headers that were set by a proxy you control, and take the entry that proxy appended, not the first value a client supplied. Otherwise an attacker changes one header per request and resets their own limit. This is a recommendation based on how the header works, so check how your platform exposes the true client address.

Algorithms, and the boundary problem

AlgorithmHow it worksTradeoff
Fixed windowCount requests per clock-aligned window (for example per minute)Simple; a caller can send a full quota at the end of one window and another at the start of the next, so up to twice the limit within a short span
Sliding windowCount over the last N seconds, continuouslySmoother; more state or computation
Token bucketA bucket refills at a steady rate; each request spends a tokenAllows controlled bursts, easy to reason about

The boundary effect in the fixed window row follows directly from how it counts. If the limit is 100 per minute, 100 requests at second 59 and 100 more at second 61 are both "within limit" in their own windows, yet 200 arrive in about two seconds. Pick sliding window or token bucket where that matters.

What the standards say about the response

The status code is 429 Too Many Requests, defined in RFC 6585. That RFC says the response may include a Retry-After header telling the client how long to wait, that such responses must not be stored by a cache, and that it does not define how you identify the user or count requests; that is up to you. The OWASP REST Security Cheat Sheet likewise says to return 429 when requests arrive too quickly.

A set of RateLimit and RateLimit-Policy response headers for advertising quotas is being worked on in the IETF HTTP API working group. At the time of writing it was still an active Internet-Draft (version 11, dated 23 May 2026, with no RFC number), so treat the field names as emerging rather than settled and do not depend on clients understanding them.

Limits that are not about request counts

Rate limiting counts requests, but a single request can be enormously expensive. OWASP's API item is titled Unrestricted Resource Consumption for a reason. Put explicit caps on:

  • Request body size. OWASP's REST cheat sheet says to reject requests over the limit with 413 Request Entity Too Large.
  • Page size. A limit parameter with no maximum lets one request ask for a million rows. Validate it with an upper bound (I show a schema below).
  • Query cost. Expensive searches, exports and reports need their own, lower limits and often a background job instead of a synchronous response.
  • Timeouts. A request that can run forever will eventually be used to hold your connections open.
  • Outbound calls. If your API fetches a URL a caller supplies (a webhook target, an avatar by URL), allow-list the destinations. Otherwise the server becomes a way to reach internal addresses. That is the idea behind the SSRF entry in the Top 10.

Rate limiter state has to be shared if you run more than one instance: a counter held in one server's memory lets a caller multiply their quota by the number of servers. A shared store, or your platform's built-in rate limiting, avoids that.

CORS: what it does, and what it does not

CORS (Cross-Origin Resource Sharing) is the mechanism by which a server tells browsers which other origins may read its responses. By default, a script on https://a.example cannot read the response of a request to https://b.example. CORS headers loosen that restriction in specific ways.

The most important sentence in this section: CORS is enforced by the browser, on the response. It does not stop a request from being sent, and it does nothing at all for requests that do not come from a browser. curl, a server-side script, and a mobile app ignore it entirely. MDN's description matches this: browsers use CORS to mitigate the risks of cross-origin requests made from scripts.

The experiment

I wrote two tiny servers on different ports of localhost (so different origins), served a page from one, and had its script call the other with fetch. The API logged everything it received. The API sent no CORS headers except where noted.

#Request from the pageWhat the script could doWhat the server actually received
1POST, Content-Type: text/plain, body transfer=100, no CORS headersBlocked (a TypeError)The full POST with its body
2POST, Content-Type: application/json, no CORS headersBlockedOnly an OPTIONS preflight. The POST was never sent
3The same JSON POST, server allows this origin and headerResponse readable (200)OPTIONS, then the POST
4Credentialed fetch, response says Allow-Origin: * with credentialsBlockedThe POST
5Credentialed fetch, explicit allowed origin but no Allow-CredentialsBlockedThe POST, with the cookie attached

Rows 1, 4 and 5 are the lesson: the browser blocked the script from reading the response, yet the server received the request and ran it. If that request had been "transfer money", the money would have moved. The block is about who can see the result.

Row 2 shows why this does not apply to every request. A request with a JSON content type is not a "simple" request, so the browser sends a preflight OPTIONS first, and without permission the actual POST is never sent. MDN defines simple requests as those using GET, HEAD or POST with only safelisted headers, and a content type of application/x-www-form-urlencoded, multipart/form-data or text/plain. Anything else is preflighted.

A caveat on row 5: both of my origins were on localhost, which browsers treat as the same site even though the ports make them different origins. That is why a SameSite=Lax cookie was attached. For truly cross-site requests, whether the cookie is sent is governed by the cookie's SameSite setting, which is a separate layer from CORS (see the CSRF section).

Two consequences for your API

1. Do not treat CORS as a defense against forged writes. A simple request, such as a form-style POST, reaches your server whether or not CORS is configured. If your handler accepts it, the side effect happens. The defense for that is CSRF protection, below.

2. Reject unexpected content types. The reason row 2's request never arrived is the JSON content type forcing a preflight. That protection disappears if your server parses a body as JSON regardless of the Content-Type header: an attacker's page can then send JSON text labeled text/plain as a simple request. OWASP's REST guidance is to validate the content type and reject unsupported ones with 415 Unsupported Media Type. Make that a rule of your body parser, not an optional check.

Configuring CORS correctly

When you do need cross-origin access, for example a frontend on one domain calling an API on another:

  • Allow-list exact origins. Respond with the specific matching origin, not *, when credentials are involved.
  • Never reflect the Origin header back blindly. Echoing whatever origin arrives is the same as allowing everyone, with credentials. Compare against an explicit list.
  • Add Vary: Origin when the allowed origin depends on the request, as MDN advises, so caches do not serve one origin's response to another.
  • Credentials need explicit values. For credentialed requests, MDN says Access-Control-Allow-Origin, -Headers, -Methods and -Expose-Headers must not use the * wildcard; you need explicit values. Row 4 shows the browser blocking a wildcard with credentials, and row 5 shows it blocking a response without Access-Control-Allow-Credentials.
  • Cache preflights. Access-Control-Max-Age defaults to 5 seconds per MDN, and browsers cap the value, so a modest explicit setting saves a round trip without promising more than the browser will honor.

CSRF: when the browser sends credentials for you

CSRF (Cross-Site Request Forgery) tricks a logged-in user's browser into sending a state-changing request to your site. It works because browsers attach cookies automatically: if the user is signed in to your app and visits a hostile page, that page can cause a request to your app that arrives with their session cookie.

The risk exists whenever authentication is something the browser attaches by itself. The cookie sessions from Part 2 qualify. A bearer token that your own JavaScript puts in an Authorization header does not have the problem, because a hostile page cannot read or attach it. That is a reason people give for token-in-header designs, but it comes with the storage tradeoffs Part 2 described, so it is not free.

The defenses, as OWASP lays them out

The OWASP CSRF Prevention Cheat Sheet recommends, roughly in this order:

  1. Use your framework's built-in CSRF protection if it has one.
  2. Tokens. For stateful apps, the synchronizer token pattern (a per-session random token that must accompany each state-changing request). For stateless apps, a signed double-submit cookie, which OWASP marks as recommended (the unsigned form is marked discouraged).
  3. Custom request headers for APIs that do not use HTML forms. A custom header forces a preflight, as row 2 showed, so a cross-origin page cannot send it without your permission.
  4. Fetch Metadata, for software that targets only modern browsers: the Sec-Fetch-Site header tells you whether a request came from the same origin, and OWASP calls it the most useful of those headers. It requires a fallback to origin verification.
  5. Origin and Referer verification as defense in depth: reject state-changing requests when the source origin cannot be established as trusted, unless another defense such as a valid token succeeds.
  6. SameSite cookies, also as defense in depth.

What SameSite does and does not do

ValueBehavior (from OWASP)
StrictCookie is not sent with cross-site requests, including top-level navigations from external links
LaxCookie is sent on cross-site top-level navigations that use safe methods, such as following a link
NoneCookie is sent in cross-site contexts and requires the Secure attribute

OWASP is direct that SameSite "does not replace a proper CSRF defense in most deployments", and allows that in narrow deployments it can suffice only if several conditions all hold. Its guidance is to treat it as a layer combined with a token or double-submit pattern. Two more rules from the same page:

  • Never change state with a GET. If any state-changing operation is reachable by GET, SameSite=Lax will not stop it, because Lax allows cookies on top-level GET navigations. If you cannot avoid it, protect those resources against CSRF anyway.
  • The experiment above applies here too. A simple cross-origin request reaches your server; only your own checks decide whether it is honored.

A practical combination for a cookie-session API: SameSite=Lax (or Strict where the UX allows), a CSRF token or custom header on every state-changing request, strict content-type handling, and no mutating GETs.

Input validation

Everything that arrives from outside is untrusted: bodies, query strings, path parameters, headers and cookies. The OWASP Input Validation cheat sheet states the principles worth building on:

  • Define what is allowed and reject everything else. Do not try to list every malicious string; a deny-list also rejects valid input (a name with an apostrophe) without making anything safe.
  • Validate syntax and semantics. Syntax is the type and format; semantics is whether the value makes sense for the operation. A booking needs valid dates, and the end must be after the start.
  • Validate on the server. Client-side checks are for user experience and can be bypassed.
  • Bound length before matching a regular expression, and avoid patterns with heavy backtracking, to avoid regex denial of service.
  • Validation is not the defense against SQL injection. Parameterized queries are. Validation is a separate layer.

Validate at the edge with a schema

A schema library gives you one place that turns untrusted input into a typed value or a rejection. I tested the following with Zod 4.6.7, because several default behaviors surprised me.

Unknown keys are silently dropped by default. A plain object schema strips fields it does not know about:

const body = z.object({ title: z.string() })
body.parse({ title: 'a', role: 'admin', isAdmin: true })
// { title: 'a' }   <- the extra keys disappear, with no error

That is safe if you use the parsed result. The classic mass assignment bug is spreading the raw request body into a database write, so a caller can set fields like role or ownerId. Parse first, then use only the parsed output. If you would rather reject than ignore, use a strict object:

const strict = z.strictObject({ title: z.string() })
strict.safeParse({ title: 'a', role: 'admin' })
// success: false, issue code 'unrecognized_keys', keys ['role']

Rejecting is noisier but surfaces client bugs and attack attempts instead of hiding them.

Coercion can turn garbage into valid-looking values. Query strings are always strings, so people reach for coercion, and I found its defaults dangerous:

z.coerce.number().parse('')          // 0      (an empty string becomes zero)
z.coerce.number().parse(null)        // 0
z.coerce.boolean().parse('false')    // true   (any non-empty string is true)
z.number().safeParse('5').success    // false  (strict number rejects the string)

An empty ?limit= becoming 0, or ?active=false becoming true, is a bug that validation was supposed to prevent. Constrain coerced values so nonsense fails:

const page = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
})
page.parse({})                       // { limit: 20 }
page.safeParse({ limit: '1000000000' }).success  // false: above the cap
page.safeParse({ limit: 'abc' }).success         // false

For booleans from query strings, parse the exact strings you accept ('true' / 'false') rather than coercing.

Also validate what you do not parse. Check the Content-Type and enforce a body-size limit before reading the body, as covered earlier. Limit string lengths (z.string().max(...)) so one field cannot carry a megabyte.

Security headers for an API

Headers are a cheap way to turn off risky browser behavior. The OWASP HTTP Headers cheat sheet lists values; here are the ones that matter for a JSON API, with its recommended settings:

HeaderRecommended valueWhy
Content-TypeAccurate, such as application/jsonA wrong or missing type lets browsers guess
X-Content-Type-OptionsnosniffStops MIME-type sniffing
Cache-Controlno-store on sensitive responsesTells compliant caches not to keep them (it does not control an application-level cache)
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadForces HTTPS; OWASP warns that a long max-age plus an expired certificate can lock users out
Content-Security-Policydefault-src 'none'; frame-ancestors 'none'Useful if the API ever returns HTML; frame-ancestors replaces X-Frame-Options
Referrer-Policystrict-origin-when-cross-origin (or no-referrer for APIs)Limits leaked URLs
Permissions-PolicyDisable unused features, such as geolocation=(), camera=(), microphone=()Stops injected scripts from enabling them

Two to remove or avoid, per the same sources: X-Powered-By and an informative Server value (they advertise your stack), and X-XSS-Protection, which OWASP says not to use (or to set to 0) because it can introduce vulnerabilities in otherwise safe sites. The preload flag in the HSTS value is a commitment that is hard to undo, so add it deliberately, not by copy-paste; that caution is mine, not OWASP's.

Errors and status codes are part of the API

Attackers read your error messages, and clients depend on your status codes. The OWASP REST cheat sheet lists the codes that matter for security:

CodeUse
400Malformed request, such as a body format error
401Missing or wrong authentication
403Authenticated but not permitted
405Method not on the allow-list for this endpoint
406No acceptable response representation
413Request body over the limit
415Unsupported content type
429Too many requests
500Server error; must not reveal internal details or stack traces

Respond with generic messages and keep technical detail in your logs, not in responses. A consistent error shape helps clients, and there is a standard for it: RFC 9457, Problem Details for HTTP APIs (which obsoletes RFC 7807), defines the application/problem+json media type with the members type, status, title, detail and instance.

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30

{
  "type": "about:blank",
  "status": 429,
  "title": "Too many requests",
  "detail": "Try again in 30 seconds."
}

Remember the tradeoff from Part 5: whether a record you may not see returns 404 or 403 is a deliberate choice, since 403 confirms the record exists.

Testing it yourself

You can check most of this from a terminal. Use a staging environment and keep your volume modest.

Does your API allow the wrong origin? Send a preflight and read the response headers:

curl -si -X OPTIONS https://api.example.com/orders \
  -H 'Origin: https://evil.example' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type' | head -20

You should not see Access-Control-Allow-Origin: https://evil.example. Also try Origin: null.

Does the API honor a mislabeled body?

curl -si -X POST https://api.example.com/orders \
  -H 'Content-Type: text/plain' -d '{"item":1}' | head -1
# you want 415 (unsupported media type), not 200

Does the rate limit work, and on what key?

for i in $(seq 1 12); do
  curl -s -o /dev/null -w '%{http_code}\n' -X POST https://staging.example.com/api/login \
    -H 'content-type: application/json' -d '{"email":"a@example.com","password":"x"}'
done | sort | uniq -c

Then repeat with a different X-Forwarded-For header to confirm the limit does not trust it.

What headers does a response carry?

curl -sI https://api.example.com/health | grep -i -E 'x-powered-by|server|strict-transport|x-content-type|cache-control'

Does validation reject what it should? Try an unknown field, an empty limit, limit=1000000000, a string where a number belongs, and a body larger than your limit, and confirm you get a 4xx each time, never a 500.

Production checklist:

  • Rate limits on sensitive flows, keyed by a trustworthy identity; shared state across instances; 429 with Retry-After.
  • Caps on body size, page size, query cost and request time.
  • CORS allow-list of exact origins; no wildcard with credentials; no blind origin reflection; Vary: Origin.
  • Strict content-type handling with 415; no JSON parsing of mislabeled bodies.
  • CSRF defense on every state-changing route that uses cookie auth; no mutating GET.
  • Schema validation at the edge; parsed output used, never the raw body; constrained coercion.
  • Parameterized queries; allow-listed outbound destinations.
  • nosniff, accurate Content-Type, no-store where sensitive, HSTS, and no stack or version headers.
  • Generic, consistent error responses; the right status code each time.
  • An inventory of endpoints and versions, including the forgotten ones.

Interview answer vs production answer

The interview answer: "Use rate limiting, enable CORS properly, protect against CSRF with tokens, validate input, and set security headers."

The production answer adds what makes each of those real:

  • CORS is not a firewall. My own test showed a blocked cross-origin request reaching the server and running. Whether the script may read the answer is a separate question from whether the request is honored.
  • The content type is a security boundary. It decides whether a request is preflighted at all.
  • SameSite is a layer, not the defense. OWASP says so explicitly, and mutating GETs bypass Lax.
  • Validation output is the only trusted data. Dropping unknown keys silently and coercing empty strings to zero are defaults you must understand before you trust them.
  • The limit is only as good as its key. Trusting a client-supplied forwarding header, or keeping counters in one instance's memory, quietly removes the protection.
  • Quiet failures are part of the design. Decide what happens when your limiter's store or your validation dependency is down.

FAQ

Does CORS protect my API from other websites?

Only in the narrow sense that browsers will not let another site's script read your responses unless you allow it. It does not stop requests from being sent, it does not stop non-browser clients, and in my test a blocked request still reached the server. Use authentication, authorization and CSRF protection for the actual defense.

Why does my request work in Postman or curl but fail in the browser?

Because the browser enforces CORS and those tools do not. The server's response is missing the CORS headers the browser needs to expose it to your script.

Do I need CSRF protection if I use JWTs?

It depends on where the token lives. A token your JavaScript attaches to an Authorization header is not sent automatically by the browser, so a hostile page cannot ride on it. A token stored in a cookie is sent automatically and needs CSRF protection like any cookie session. Part 2 covers the storage tradeoffs.

Is SameSite=Lax enough?

OWASP says it is not a replacement for a proper CSRF defense in most deployments and should be combined with a token or double-submit pattern. It also does not help against state-changing GET requests.

Should I use X-Forwarded-For for rate limiting?

Only the value appended by a proxy you control. The header can be set by the caller, so trusting its first value lets an attacker change their apparent address on every request.

What is mass assignment?

Writing a request body directly into a record, so a caller can set fields you never meant to expose, such as role or ownerId. Avoid it by validating against a schema and using only the validated output.

What is the difference between a 401 and a 403?

401 means the caller is not authenticated (missing or wrong credentials). 403 means they are authenticated but not allowed to do this.

Is the RateLimit header standard?

Not yet. When I checked, it was an active IETF Internet-Draft rather than an RFC, so treat it as emerging. 429 and Retry-After are the established parts.

Glossary

TermSimple meaning
OriginScheme + host + port of a page or API
CORSBrowser rules about which origins' scripts may read a response
PreflightAn OPTIONS request the browser sends before a non-simple cross-origin request
Simple requestA cross-origin GET, HEAD or POST with only safelisted headers and content types
CSRFTricking a signed-in browser into sending an unwanted request
Synchronizer tokenA per-session random value required on state-changing requests
Double-submit cookieA token sent both as a cookie and in the request, and compared
SameSiteCookie attribute limiting when cookies are sent on cross-site requests
Fetch MetadataRequest headers such as Sec-Fetch-Site describing where a request came from
Rate limitA cap on how much one caller can do in a period
Token bucketA rate algorithm that refills tokens steadily and spends one per request
Mass assignmentWriting unvalidated request fields straight into a record
HSTSA header telling browsers to use HTTPS only
Problem DetailsRFC 9457 standard JSON error format

The end-to-end mental model

 request from the internet
          |
          v
 TLS terminates; security headers added on the way out
          |
          v
 RATE LIMIT  (key: trusted identity; caps on body, page size, cost)  -> 429 + Retry-After
          |
          v
 BROWSER-ORIGIN CHECKS
   CORS    : who may READ the response     (browser-enforced; not access control)
   CSRF    : was this request intended?    (token / custom header / Sec-Fetch-Site / Origin)
          |
          v
 PARSE + VALIDATE
   content type allowed?  -> 415
   size within limit?     -> 413
   schema (allow-list)    -> 400 ; use parsed output only
          |
          v
 AUTHENTICATE (Parts 2-4)  ->  AUTHORIZE (Part 5)  ->  handler
          |
          v
 generic, consistent errors (RFC 9457); details stay in the logs

Part 5 gave the system a rulebook; this part guarded the entrance. Neither helps much if, when something does go wrong, you cannot tell what happened. The next step in the series is what you do after the request is handled: error handling, logging and observability in Node.js backends.

About the author

Suriyaprakash Somu is a full-stack developer based in Erode, Tamil Nadu, building React and Node.js applications on relational databases. He works on customer-facing experiences, reusable interfaces and access-controlled backend workflows. Explore his development work or discuss a project or developer role.