Configure HTTPS and Security Headers
Stand up a local HTTPS certificate with mkcert, serve a small web app over TLS, and add the five essential security response headers — testing each one with curl and browser DevTools until you can prove it's actually doing its job.
Problem
Two of the cheapest, highest-leverage security controls you can add to any web application are: (1) serving it over HTTPS instead of plaintext HTTP, and (2) sending a handful of response headers that tell the browser how to treat your content defensively. Neither requires touching your business logic. Both are frequently skipped in local development — which means developers never actually see them work, and they get bolted on (or forgotten) at the last minute before a real release. Without HTTPS, every request and response — including cookies, session tokens, and form submissions — travels in plaintext. Anyone on the same network (a coffee-shop Wi-Fi, a compromised router, a malicious proxy) can read or tamper with it. Without security headers, a browser will happily let your page be framed by an attacker's site (clickjacking), let a malformed upload be sniffed and executed as a script (MIME confusion), or load attacker-controlled scripts if any part of your page reflects unsanitized input (XSS) — protections the browser is *willing* to enforce, but only if you tell it to. In this lab you will set up a trusted local HTTPS certificate with `mkcert`, serve a minimal app over it, and layer in the five headers the DARE method's own security checklist calls out as mandatory in production: `Strict-Transport-Security`, `X-Frame-Options`, `X-Content-Type-Options`, `Content-Security-Policy`, and `Referrer-Policy`. You will not just add them — you will verify each one is actually taking effect, using `curl` and your browser's DevTools, the same way you'd verify it in a real deployment. > This lab targets a local development server only (`localhost` / > `127.0.0.1`). Everything you configure here — the certificate, the > headers — is exactly what you'd carry into a real deployment, but the > testing happens entirely on your own machine.
Objectives
By the end of this lab you will be able to:
- Explain what a self-signed vs. locally-trusted (
mkcert) certificate is,
and why browsers reject one but not the other. - Generate a locally-trusted TLS certificate and serve a web app over
HTTPS on your own machine. - Explain what each of the five essential security headers does:
Strict-Transport-Security,X-Frame-Options,X-Content-Type-Options,
Content-Security-Policy,Referrer-Policy. - Add all five headers to a real HTTP server response, using either a
minimal hand-written server or a framework middleware. - Verify each header is present and doing its job using
curl -Iand
browser DevTools' Network and Console tabs — including watching a CSP
violation actually get blocked.
Prerequisites
- Node.js 18+ or Python 3.9+ installed (the lab shows both; pick one).
-
mkcertinstalled (brew install mkcerton macOS,choco install mkcert
on Windows, or the equivalent for your Linux distro — see
https://github.com/FiloSottile/mkcert for platform-specific instructions). - A modern browser (Chrome, Firefox, or Edge) with DevTools.
-
curlavailable in your terminal (pre-installed on macOS/Linux; ships
with modern Windows).
What you will build
A tiny local web app served over HTTPS with a browser-trusted certificate,
responding to every request with all five essential security headers —
and a checklist of curl/DevTools commands that prove each one works.
Why HTTPS locally, not just in production
It's tempting to treat HTTPS as "a deployment concern" and develop
entirely over plain HTTP. Two things break that assumption:
-
Some browser APIs require a "secure context" — service workers,
the Clipboard API, geolocation on some browsers, and more simply refuse
to work overhttp://(except onlocalhost, which gets a narrow
exemption). If your production app uses any of these, developing over
plain HTTP hides bugs you'll only find after deploying. -
HSTS and cookie
Secureflags behave differently locally vs. in prod
if you never test them — and testing them requires an actual HTTPS
connection, self-signed or not.
mkcert solves the annoying part: browsers reject plain self-signed
certificates with a scary warning, because nothing vouches for them.
mkcert creates a local certificate authority, installs its root
certificate into your system/browser trust store, and then issues
certificates for localhost that your browser trusts completely — no
warnings, no manual "proceed anyway" clicks.
The five headers and what each one buys you
| Header | What it does | What it stops |
|---|---|---|
Strict-Transport-Security |
Tells the browser "always use HTTPS for this origin, for N seconds" | Downgrade attacks — a user typing http:// or clicking an old http:// link gets silently upgraded before any request leaves the browser |
X-Frame-Options |
Tells the browser whether this page may be embedded in an <iframe>
|
Clickjacking — an attacker overlaying your page invisibly to trick users into clicking your buttons |
X-Content-Type-Options |
Tells the browser not to guess ("sniff") a response's content type | MIME-confusion attacks — a file uploaded as .txt being sniffed and executed as JavaScript |
Content-Security-Policy |
Tells the browser exactly which sources scripts/styles/images/etc. may load from | The blast radius of XSS — even if an attacker injects a <script> tag, the browser refuses to run it if its source isn't allowlisted |
Referrer-Policy |
Tells the browser how much of the current URL to leak in the Referer header of outgoing requests/links |
Leaking sensitive path/query data (session identifiers, search terms, internal URLs) to third-party sites your page links to |
Notice the pattern: every one of these is the browser enforcing a rule
you hand it. You are not implementing security logic yourself — you are
telling an already-security-conscious piece of software (the browser)
which of its built-in defenses to turn on for your origin. That's what
makes headers such high leverage: a few lines of server config activate
protections that would otherwise require significant client-side code.
How to work through this lab
Do the five steps in order. Steps 1–2 get you a trusted HTTPS server;
Steps 3–4 add and verify the headers; Step 5 ties it together and adds a
CSP violation you can watch get blocked live.
Steps
-
Generate a locally-trusted certificate with mkcert
Install
mkcertand set up its local certificate authority. This is a
one-time step per machine.# macOS brew install mkcert brew install nss # only needed if you use Firefox # Windows (with Chocolatey) choco install mkcert # Linux — see https://github.com/FiloSottile/mkcert#linux for your distro, # or download the prebuilt binary from the releases page.Install the local CA into your system/browser trust stores, then issue
a certificate forlocalhost:mkcert -install mkdir certs && cd certs mkcert localhost 127.0.0.1 ::1This produces two files:
localhost+2.pem(the certificate) and
localhost+2-key.pem(the private key). Keep both — you'll point your
server at them in Step 2.Why this is different from a plain self-signed certificate
A bare self-signed certificate (e.g. via
openssl req -x509 ...) is
cryptographically valid but vouched for by nobody — your browser
has no reason to trust it, so it shows a full-page warning.mkcert
instead creates its own private root CA and installs that CA's
certificate into your OS/browser trust store. Every certificate
mkcertissues afterward is trusted transitively, exactly the way a
real CA-issued production certificate is trusted — just scoped to
your machine only.Checkpoint
Confirm the certificate files exist and are readable:
ls -la localhost+2.pem localhost+2-key.pem openssl x509 -in localhost+2.pem -noout -subject -datesYou should see a
subjectline mentioninglocalhostand a validity
window (notBefore/notAfter) covering today's date.Deliverable for this step: a
certs/directory containing a valid
mkcert-issued certificate and key forlocalhost, confirmed via
openssl x509. -
Serve a small app over HTTPS
Write a minimal server that terminates TLS using the certificate from
Step 1. Pick your stack.Node.js track
// server.js const https = require("https"); const fs = require("fs"); const options = { key: fs.readFileSync("certs/localhost+2-key.pem"), cert: fs.readFileSync("certs/localhost+2.pem"), }; const server = https.createServer(options, (req, res) => { res.writeHead(200, { "Content-Type": "text/html" }); res.end("<h1>Hello over HTTPS</h1>"); }); server.listen(8443, () => { console.log("Listening on https://localhost:8443"); });node server.jsPython track
# server.py import http.server import ssl class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header("Content-Type", "text/html") self.end_headers() self.wfile.write(b"<h1>Hello over HTTPS</h1>") httpd = http.server.HTTPServer(("localhost", 8443), Handler) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain( certfile="certs/localhost+2.pem", keyfile="certs/localhost+2-key.pem", ) httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) print("Listening on https://localhost:8443") httpd.serve_forever()python server.pyVisit it in the browser
Open
https://localhost:8443in your browser. You should see the
page load with no certificate warning at all and a padlock icon
in the address bar — this ismkcert's local trust working exactly
as intended.Checkpoint
Confirm the TLS handshake and certificate chain from the command line:
curl -v https://localhost:8443 2>&1 | grep -A2 "SSL certificate verify" curl -I https://localhost:8443curlshould complete the request without-k/--insecure— if
you needed-kto make it work, the certificate isn't trusted
correctly and you should re-runmkcert -installand confirm you
pointed the server at the right.pemfiles.Deliverable for this step: a running HTTPS server on
https://localhost:8443, loading in the browser with a valid padlock
and no warnings, and a successfulcurl -Iwithout--insecure. -
Add the five security headers
Add all five headers to every response. Extend the server from
Step 2.Node.js track
// server.js — updated handler const SECURITY_HEADERS = { "Strict-Transport-Security": "max-age=31536000; includeSubDomains", "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'self'", "Referrer-Policy": "strict-origin-when-cross-origin", }; const server = https.createServer(options, (req, res) => { for (const [name, value] of Object.entries(SECURITY_HEADERS)) { res.setHeader(name, value); } res.writeHead(200, { "Content-Type": "text/html" }); res.end(` <h1>Hello over HTTPS</h1> <img src="https://example.com/tracker.png" alt="external image"> `); });Python track
# server.py — updated handler SECURITY_HEADERS = { "Strict-Transport-Security": "max-age=31536000; includeSubDomains", "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'self'", "Referrer-Policy": "strict-origin-when-cross-origin", } class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header("Content-Type", "text/html") for name, value in SECURITY_HEADERS.items(): self.send_header(name, value) self.end_headers() self.wfile.write(b""" <h1>Hello over HTTPS</h1> <img src="https://example.com/tracker.png" alt="external image"> """)The
<img>tag pointing atexample.comis there on purpose — you'll
watch theContent-Security-Policyyou just set block it in Step 5.What each value means, precisely
-
Strict-Transport-Security: max-age=31536000; includeSubDomains
— cache "always use HTTPS" for one year (31536000seconds), and
apply it to every subdomain too. Note: browsers only honor this
header over an HTTPS connection — it's meaningless sent over
plain HTTP, which is exactly why the very first HTTPS request
matters. -
X-Frame-Options: DENY— never allow this page in a frame, on
any site including your own. UseSAMEORIGINinstead if you
legitimately frame your own pages elsewhere in your app. -
X-Content-Type-Options: nosniff— take theContent-Typewe
send at face value; never guess a different type from the bytes. -
Content-Security-Policy: default-src 'self'— for every
resource type not otherwise specified (scripts, styles, images,
fonts, connections), only allow loading from this same origin.
This is the strictest possible starting policy; real apps usually
need to loosen specific directives (e.g.img-srcfor a CDN),
never widendefault-srcitself if you can avoid it. -
Referrer-Policy: strict-origin-when-cross-origin— send the
full URL asRefererfor same-origin requests, but only the origin
(no path/query) for cross-origin ones, and nothing at all when
downgrading from HTTPS to HTTP.
Restart your server with this updated handler before moving on.
Checkpoint
Confirm the server starts without errors and still responds:
curl -I https://localhost:8443You should see all five header names in the raw response, though
you'll verify their values precisely in Step 4.Deliverable for this step: an updated server that sends all five
headers on every response, confirmed running. -
-
Verify each header with curl
Adding a header and verifying it took effect are different skills.
Run each check below and confirm the exact value, not just presence.curl -sI https://localhost:8443You should see something like:
HTTP/1.1 200 OK Content-Type: text/html Strict-Transport-Security: max-age=31536000; includeSubDomains X-Frame-Options: DENY X-Content-Type-Options: nosniff Content-Security-Policy: default-src 'self' Referrer-Policy: strict-origin-when-cross-originNow check each one individually, which forces you to notice typos or
wrong casing that a quick glance would miss:curl -sI https://localhost:8443 | grep -i "strict-transport-security" curl -sI https://localhost:8443 | grep -i "x-frame-options" curl -sI https://localhost:8443 | grep -i "x-content-type-options" curl -sI https://localhost:8443 | grep -i "content-security-policy" curl -sI https://localhost:8443 | grep -i "referrer-policy"Each
grepshould print exactly one line with the value you set in
Step 3. If any line is missing, re-check your handler code for a typo
in the header name (header names are case-insensitive on the wire,
but a misspelled name likeX-Frame-Option— missing thes— is a
silent no-op).Cross-check in the browser
Open
https://localhost:8443, open DevTools, go to the Network
tab, reload, click the top-level document request, and look at
Response Headers. You should see the same five headers. This is
worth doing even after thecurlcheck — it's exactly where you'll
look when debugging headers on a real deployed site later, since you
won't always havecurlaccess to a production origin the same way.A common gotcha: reverse proxies and CDNs
In a real deployment, a reverse proxy or CDN in front of your app can
strip or override headers your application code sets. If a header
you added in your app code doesn't show up incurl/DevTools against
the live, publicly reachable URL, the app is not always the culprit —
check the proxy/CDN configuration too. This lab's setup has no proxy
in front, so what you see is exactly what your code sent — useful for
learning the headers themselves before you have to debug through a
proxy layer.Checkpoint
Fill in this table for your own server (all five should be
"present" and "correct"):Header Present? Value matches Step 3? Strict-Transport-Security X-Frame-Options X-Content-Type-Options Content-Security-Policy Referrer-Policy Deliverable for this step: the filled-in table above, plus a copy
of the rawcurl -sIoutput showing all five headers. -
Watch CSP block a real violation, then submit
The best proof a header works isn't reading its value — it's watching
the browser actually enforce it. You already planted a violation in
Step 3: the<img src="https://example.com/tracker.png">tag, which
yourContent-Security-Policy: default-src 'self'should block.Watch the block happen
- Open
https://localhost:8443in your browser. - Open DevTools → Console tab.
- Reload the page.
You should see a CSP violation message in the console, similar to:
Refused to load the image 'https://example.com/tracker.png' because it violates the following Content Security Policy directive: "default-src 'self'".Then check the Network tab: the request to
example.comeither
never appears at all, or shows as blocked — the browser refused to
even attempt it. This is the practical difference between CSP and
"just filtering on the server": CSP is enforced client-side,
before the network request happens, so it stops leaks the server-side
code can't see (e.g. an attacker-injected<img>tag exfiltrating
data via the URL to a domain the server never touches).Try loosening the policy and re-test
To see the CSP directive syntax in action, allow just images from
example.comwithout opening everything else up:Content-Security-Policy: default-src 'self'; img-src 'self' https://example.comUpdate your server's header value to this, restart, reload the page,
and confirm in the Console that the violation is now gone — the image
request succeeds (or fails only for network reasons, not CSP). This
demonstrates the correct pattern for real apps: start from
default-src 'self'and add narrowly-scoped exceptions per directive,
never a blanket loosening.Also confirm HSTS behavior
Confirm the
Strict-Transport-Securityheader only appears over
HTTPS, never plain HTTP (if you have a plain-HTTP variant of your
server, or can temporarily comment out the TLS wrapping to test):# over HTTPS — header present curl -sI https://localhost:8443 | grep -i strict-transport-security # a plain-HTTP request has nothing to carry HSTS on — the header # would be meaningless there, which is why browsers ignore it if a # server mistakenly sends it over plain HTTPSubmission criteria
Submit when all of the following hold:
- An HTTPS server (Node.js or Python) running on
https://localhost:8443with anmkcert-issued, browser-trusted
certificate (no warnings, nocurl --insecureneeded). - All five headers present with correct values, verified via the
filled-in table from Step 4. - A screenshot or copied Console log showing the CSP violation being
blocked forhttps://example.com/tracker.pngunder the strict
default-src 'self'policy. - The loosened
img-srcpolicy applied and re-verified, with a short
note explaining why scoping the exception toimg-srcis safer
than wideningdefault-src.
- Open