Skip to main content

Command Palette

Search for a command to run...

HTTP Security Headers: The Cheapest Security Your API Will Ever Ship

The response headers worth sending on everything, the request headers you must never trust, and the CORS line that quietly opens the door.

Updated
9 min read
HTTP Security Headers: The Cheapest Security Your API Will Ever Ship
K
I am a Lead Full Stack Engineer with 6.5+ years of experience building scalable cloud-native platforms, distributed systems, and production-grade applications across telecom, fintech, govtech, and edtech domains. My core strength is backend engineering with Java, Spring Boot, microservices, and AWS, but I work across the entire delivery pipeline — from schema design and APIs to frontend interfaces and deployment systems. I describe my engineering style with one line: “I ship end-to-end. Schema to surface. No handoffs.” I believe strong engineering comes from ownership, not isolated specialization. The same engineer who designs the service should understand the UI consuming it, the deployment pipeline running it, and the metrics validating it in production. That mindset has shaped how I build systems, mentor teams, and deliver software. Over the years, I have worked on carrier-scale enterprise platforms, CRM modernization systems, loan-processing applications, real-time tutoring infrastructure, and department-scale governance portals. Across every domain, the engineering discipline remains the same: understand the problem deeply, design clear system boundaries, instrument what matters, and deliver measurable outcomes. My backend stack primarily revolves around Java, Spring Boot, Spring Cloud, distributed microservices, REST APIs, authentication systems, caching, resiliency patterns, and performance optimization. I have also built extensively using Node.js and NestJS for modern service architectures. On the frontend side, I work with React, Angular, TypeScript, and React Native to deliver responsive and scalable user experiences. I have hands-on experience with cloud-native infrastructure and DevOps workflows using AWS services like EC2, Lambda, S3, ECR, RDS, CloudWatch, CodeBuild, and CodePipeline, along with Docker, Jenkins, SonarQube, Grafana, ELK Stack, and CI/CD automation. I care deeply about observability, operational visibility, and systems that remain maintainable under scale. One thing that defines my approach is that every system should move a metric. I focus on engineering outcomes — improving performance, reducing operational friction, increasing delivery speed, simplifying developer workflows, or creating better user experiences. If a feature does not create measurable lift, it is incomplete. I am also deeply interested in modern AI-assisted engineering workflows. I actively use tools like GitHub Copilot, Claude, Gemini, Cursor, and agentic development systems to accelerate development, improve productivity, and rethink how software teams build products at scale. Beyond coding, I enjoy mentoring engineers, improving engineering standards, reviewing architectures, and building systems that other developers can scale confidently. I value clarity over complexity, practical execution over theoretical perfection, and shipping over endless planning. Today, my focus areas include distributed systems, platform engineering, cloud-native architecture, AI-powered developer tooling, scalable backend infrastructure, and modern full-stack application design. Backend-deep. Full-stack by delivery. Schema to surface. Service to screen. No handoff costs.

Some of the strongest security on your API isn't in your code at all. It's in a handful of headers — a few lines you attach to every response, which the browser then enforces on your behalf, for free, on hardware you'll never see. Skip them and nothing breaks. The app works fine in the demo, ships fine, runs fine for a year. That's exactly why they're so easy to never set.

There are two halves to this, and most people only ever think about one. Response headers are orders you give the browser. Request headers are claims the client makes about itself — and the client can lie about every single one. Get that trust direction backwards and you end up building an auth check on top of a field anyone can forge with a one-line curl.

Here's the whole picture, then the specifics.

Two kinds of header, two levels of trust

A request header is set by whoever made the request. Your frontend, a browser, curl, a scraper, an attacker with a terminal — all equal, all anonymous. User-Agent, Host, Origin, X-Forwarded-For: every one of them is free text the sender chose to type. Treat them as evidence to verify, never as proof of anything.

A response header is set by you, the server. The interesting ones are instructions the browser is more or less contractually obliged to follow: only ever talk to me over HTTPS, refuse to run inline script, don't let this page be put in a frame. You write one line; the browser enforces it for every user who visits.

Request headers are set by the client and can all be forged, so they're evidence to verify; response headers are set by the server and the browser obeys them.

So the rule has two sides and they're mirror images. Outbound: send the headers that switch on the browser's built-in defenses. Inbound: assume every header is hostile until you've checked the thing it claims.

The defensive set

These are the response headers worth attaching to basically everything. None of them cost you anything at runtime, and you set them in one place, not per route.

The defensive set: HSTS, CSP, X-Content-Type-Options, frame-ancestors, Referrer-Policy, Permissions-Policy, and dropping Server / X-Powered-By — each mapped to the attack it stops.

Strict-Transport-Security. HSTS. max-age=63072000; includeSubDomains; preload. It tells the browser "for the next two years, only ever reach me over HTTPS" — so the downgrade trick, where someone on the network quietly answers your first plain-HTTP request before the redirect kicks in, stops working. It's only honored over HTTPS in the first place. The preload flag opts you into a list baked straight into browsers, but it's sticky and a pain to back out of, so don't add it until you mean it.

Content-Security-Policy. The big one, and the fiddly one. CSP tells the browser which sources of script, style, images and so on it's allowed to load and run — so even if an attacker manages to inject a <script> into your page, the browser simply refuses to execute it. default-src 'self' is the strict starting point. Roll it out with Content-Security-Policy-Report-Only first, so you get reports of what it would block without actually breaking your own app. This is the header that neuters most XSS, which is why it's worth the tuning it asks for.

X-Content-Type-Options: nosniff. One value, no options. It stops the browser second-guessing your Content-Type and "helpfully" deciding that the thing you served as plain text is actually executable script. Cheap to set, closes a whole class of MIME-confusion bugs.

X-Frame-Options / frame-ancestors. Clickjacking defense — it stops other sites loading your page inside an invisible iframe and tricking your logged-in users into clicking things they can't see. X-Frame-Options: DENY is the old header; CSP's frame-ancestors 'none' is the modern replacement. Set both for now — old browsers only understand the first.

Referrer-Policy. Controls how much of your URL leaks in the Referer header when a user clicks a link out to another site. strict-origin-when-cross-origin is a sane default; reach for no-referrer if your URLs carry anything sensitive (tokens in query strings — which you shouldn't have, but here we all are).

Permissions-Policy. Switches off browser features you don't use. geolocation=(), camera=(), microphone=() says "nothing on this origin may even ask for these" — so an injected script can't quietly try, either.

And one in reverse: stop advertising your stack. Drop Server and X-Powered-By. They won't get you owned on their own, but they hand any passing scanner your framework and version for free, which is the first step of every "find a known CVE for this exact version" script. In Express it's one line: app.disable("x-powered-by").

You don't hand-set all of this on every route. Put it in one place — middleware, or better, the reverse proxy / CDN at the edge — so it's on by default and can't be forgotten. In Express, Helmet does most of the set for you:

import helmet from "helmet";
app.use(helmet());        // HSTS, nosniff, frame, referrer, a baseline CSP, and more
app.disable("x-powered-by");

Then check your work from the outside. Point securityheaders.com or Mozilla Observatory at a live URL and it'll grade the lot in seconds and tell you what's missing.

The headers you must never trust

Now the inbound side, where the bugs are quieter and a lot meaner. Every item here is a request header someone can set to whatever they like.

X-Forwarded-For. The classic. Behind a proxy, the client's real IP gets put here, so people reach for it to rate-limit, geo-block, or allowlist. The problem: the client can simply send their own X-Forwarded-For, and now your "trusted IP" is whatever string they pasted in. The only value you can trust is the one your own proxy appended — which means telling your framework exactly how many proxies sit in front of you and reading only that hop.

// You run exactly one proxy you control (your load balancer).
// Trust that one hop — no more, no less.
app.set("trust proxy", 1);
// Now req.ip is the value your proxy vouched for, not whatever the client claimed.

Trust the whole header blindly and your IP allowlist becomes a polite suggestion.

Host. A real browser sets it honestly; an attacker is under no such obligation. If you build absolute URLs out of the Host header — password-reset links are the textbook case — someone can send a forged Host, and the reset email you generate now points your victim at the attacker's server. Validate Host against an allowlist of domains you actually serve, and never splice it straight into a link.

Origin and Referer. Useful signals, not authentication. They tell you where a browser thinks a request came from, which is handy as one input to a CSRF check — but they can be absent, and outside a browser they're whatever the sender wants. Use them as a hint, never as the lock.

Content-Type. A claim about the body, not a guarantee about it. Don't let it decide how far you trust the payload, and don't lean on it as CSRF protection — "we only accept application/json" falls over the second someone sends text/plain with a JSON body in it.

The thread through all of these: a request header is the sender describing themselves. Verify the thing it claims — the token's signature, the IP your proxy vouched for, the domain on your allowlist — and never trust the claim on its own.

The two CORS lines that quietly open the door

CORS gets its own section, because it's the one people most often "fix" straight into a hole. The setup: a browser won't let evil.com's JavaScript read a response from your-api.com unless your API explicitly says it's allowed, via Access-Control-Allow-Origin.

Here's the bug nearly everyone ships at least once:

Access-Control-Allow-Origin: <reflects whatever Origin the request sent>
Access-Control-Allow-Credentials: true

Reflecting the request's Origin straight back means every site is allowed. Add Allow-Credentials: true on top and you've also told the browser to send cookies — so any malicious page your logged-in user happens to visit can now make authenticated calls to your API and read the answers. You didn't open a door; you took down the wall.

The fix is an explicit allowlist, and only ever echoing an origin that's on it:

const ALLOWED = new Set(["https://app.example.com"]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (ALLOWED.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin"); // so a cache can't serve one origin's header to another
  }
  next();
});
The CORS hole: reflecting any Origin plus Allow-Credentials true lets any site read authenticated responses. The fix: echo only allowlisted origins, add Vary, and keep real authorization in the handler.

And the thing CORS is not: it is not server-side access control. All it governs is whether a browser hands the response back to the page's script. The request still arrived, your handler still ran, your database was still queried — curl ignores CORS entirely. So CORS protects your users' browsers from leaking data cross-origin; it does nothing to stop a direct attacker hitting your endpoint. Your real authorization has to live in the handler, every time.

"But mine's a pure JSON API"

Fair, and worth being honest about. A couple of these — CSP, X-Frame-Options — really only bite when a browser renders your response as a page. If your API exclusively returns JSON to server-side clients that no browser ever touches, those two are belt-and-suspenders.

But HSTS still matters the moment anyone reaches you over HTTP. nosniff still matters. CORS absolutely still matters as soon as any browser talks to you. And every request-header trap above applies no matter who's calling, browser or not. So the inbound rules aren't optional just because you skipped the HTML — those are the ones that quietly cost you the most.

Ship it

Put the response set in one place — middleware or the edge — so it's on by default and can't be dropped per route. Send HSTS, nosniff, a real CSP (report-only first), frame protection, Referrer-Policy and Permissions-Policy, and strip the stack-advertising headers. On the way in: pick a trusted-proxy count and read only that hop, validate Host, treat Origin and Content-Type as hints, and verify tokens instead of just decoding them. Then run the live URL through a header scanner and fix whatever it grades you down for.

The whole thing is maybe twenty lines and one afternoon. It's the cheapest security you'll ever ship — which is precisely why it's the most often skipped.