Security · 45 min

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:

Prerequisites

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:

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

  1. Generate a locally-trusted certificate with mkcert

    Install mkcert and 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 for localhost:

    mkcert -install
    mkdir certs && cd certs
    mkcert localhost 127.0.0.1 ::1
    

    This 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
    mkcert issues 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 -dates
    

    You should see a subject line mentioning localhost and a validity
    window (notBefore/notAfter) covering today's date.

    Deliverable for this step: a certs/ directory containing a valid
    mkcert-issued certificate and key for localhost, confirmed via
    openssl x509.

  2. 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.js
    

    Python 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.py
    

    Visit it in the browser

    Open https://localhost:8443 in your browser. You should see the
    page load with no certificate warning at all and a padlock icon
    in the address bar — this is mkcert'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:8443
    

    curl should complete the request without -k/--insecure — if
    you needed -k to make it work, the certificate isn't trusted
    correctly and you should re-run mkcert -install and confirm you
    pointed the server at the right .pem files.

    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 successful curl -I without --insecure.

  3. 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 at example.com is there on purpose — you'll
    watch the Content-Security-Policy you 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 (31536000 seconds), 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. Use SAMEORIGIN instead if you
      legitimately frame your own pages elsewhere in your app.
    • X-Content-Type-Options: nosniff — take the Content-Type we
      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-src for a CDN),
      never widen default-src itself if you can avoid it.
    • Referrer-Policy: strict-origin-when-cross-origin — send the
      full URL as Referer for 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:8443
    

    You 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.

  4. 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:8443
    

    You 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-origin
    

    Now 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 grep should 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 like X-Frame-Option — missing the s — 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 the curl check — it's exactly where you'll
    look when debugging headers on a real deployed site later, since you
    won't always have curl access 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 in curl/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 raw curl -sI output showing all five headers.

  5. 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
    your Content-Security-Policy: default-src 'self' should block.

    Watch the block happen

    1. Open https://localhost:8443 in your browser.
    2. Open DevTools → Console tab.
    3. 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.com either
    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.com without opening everything else up:

    Content-Security-Policy: default-src 'self'; img-src 'self' https://example.com
    

    Update 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-Security header 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 HTTP
    

    Submission criteria

    Submit when all of the following hold:

    1. An HTTPS server (Node.js or Python) running on
      https://localhost:8443 with an mkcert-issued, browser-trusted
      certificate (no warnings, no curl --insecure needed).
    2. All five headers present with correct values, verified via the
      filled-in table from Step 4.
    3. A screenshot or copied Console log showing the CSP violation being
      blocked for https://example.com/tracker.png under the strict
      default-src 'self' policy.
    4. The loosened img-src policy applied and re-verified, with a short
      note explaining why scoping the exception to img-src is safer
      than widening default-src.