Skip to content
Chapter 10Lesson 4

HTTPS on localhost with mkcert

Build the TLS and certificate model, then use mkcert to give your local dev server the trusted HTTPS production runs on.

Modern browsers gate a growing set of APIs behind one condition: the page must be a secure context. HTTPS always qualifies. Plain http://localhost qualifies too, but only partially, and the exception varies by browser. The moment you test from your phone over the LAN, set a Secure cookie, or register a service worker, the exception drops away and code that “worked on localhost” breaks in preview.

A one-time, five-minute setup fixes this: install mkcert, let it create a local Certificate Authority your machine trusts, issue a certificate for localhost, and point Next.js at it. From then on you develop on https://localhost:3000 with a green padlock, matching production from day one. Along the way you’ll build the mental model of TLS behind that padlock: what a certificate is, what makes a browser trust it, and why a hand-rolled openssl self-signed cert fails where mkcert succeeds.

Secure contexts and the localhost exception

Section titled “Secure contexts and the localhost exception”

The browser exposes a boolean, window.isSecureContext. It is true on HTTPS pages and, as a special case, on http://localhost, http://127.0.0.1, and any http://*.localhost hostname. Powerful APIs read this boolean as their gate: the Clipboard API, Web Crypto’s subtle interface, Service Workers, the Push API, and geolocation all refuse to run when it is false. The gate exists because these APIs touch sensitive material such as keys, clipboard contents, and background scripts, which a network attacker on plain HTTP could observe or hijack.

The localhost exception is only partial, in three ways you will hit:

  • Secure cookies are unreliable over plain HTTP. Some browsers silently drop a cookie marked Secure when it is set over plain HTTP, even on localhost; others keep it. HTTPS removes the inconsistency, so you can develop with the recommended cookie defaults instead of working around them.
  • The exception covers only localhost and 127.0.0.1. Serve to a teammate over a LAN IP like 192.168.1.42:3000, or open the preview on your phone via your machine’s .local hostname, and the bypass disappears: navigator.clipboard.writeText throws and crypto.subtle is undefined. The same code that “worked on localhost” breaks on the LAN.
  • Service Workers and Push always require real HTTPS, even on localhost. They are outside this course’s stack, but they are a third place the exception does not apply.

So a secure context is not one yes-or-no question; it depends on both the URL and the API. Switching every project to HTTPS on day one removes the ambiguity, and the recurring “but it worked on localhost” problem with it.

A fresh TLS 1.3 handshake takes one round trip: three messages across two flights. It settles two things at once: that the server is who it claims to be, and a pair of session keys both sides use to encrypt the rest of the conversation. The diagram shows the whole shape.

%%{init: {'themeCSS': '.messageText, .messageText tspan, .actor, .actor tspan, .noteText, .noteText tspan, .labelText, .labelText tspan { font-size: 18px !important; }'} }%%
sequenceDiagram
    participant C as Client
    participant S as Server

    rect rgba(56, 189, 248, 0.12)
        Note over C: cipher suites · key share · SNI=example.com · ALPN=[h3, h2, http/1.1]
        C->>S: ClientHello
    end

    rect rgba(34, 197, 94, 0.12)
        Note over S: chosen cipher suite · cert chain · server key share · signature
        S->>C: ServerHello + Certificate + Finished
    end

    rect rgba(244, 114, 182, 0.12)
        Note over C,S: keys derived · application data flows
        C->>S: Finished + HTTP request
    end
TLS 1.3 in one round trip: ClientHello, then the server flight with the certificate chain, then Finished and the first HTTP request on the same flight. Over HTTP/3 the handshake folds into the QUIC transport setup, as seen in The four network stages earlier in this chapter.

Phase 1: ClientHello. The client opens with the cipher suites it speaks, a fresh random value, and its half of a Diffie–Hellman key exchange (its “key share”). Two extensions ride along. SNI carries the hostname in the clear, so a server hosting many sites on one IP can pick the matching certificate. ALPN lists the application protocols the client speaks (h3, h2, http/1.1), so the server can pick one. Without SNI a CDN can’t choose a cert; without ALPN, HTTP/3 can’t be negotiated on the same handshake.

Phase 2: ServerHello + Certificate + Finished. The server picks one cipher suite, returns its own key share, sends the certificate chain (the next section covers that), and signs a transcript hash of the handshake so far with the private key matching the leaf cert. That signature proves the server owns the cert: anyone can send a public certificate, but only the holder of the matching private key can produce a signature the cert’s public key verifies.

Phase 3: Client Finished and application data. Both sides now hold both halves of the Diffie–Hellman exchange, so they derive the same session keys. The client sends its Finished message, an authenticated check that nothing tampered with the handshake bytes, and immediately follows it with the first HTTP request on the same flight. That is one round trip from ClientHello to the first byte of application data.

What makes this design hold up is forward secrecy : the session keys come from per-connection key shares that never travelled in the clear, so even if the server’s long-term private key leaks years later, recorded sessions from today can’t be decrypted. TLS 1.3 always provides this for normal traffic.

A certificate is a public key plus metadata (the hostnames it’s valid for, the dates it’s valid between, and what it can be used for) wrapped in a signature from a Certificate Authority . The browser won’t trust a public key just because it arrived in a handshake, since anyone can mint a key pair. It trusts the key only when an authority it already trusts vouches for it, and that trust starts from a small set of root CAs preinstalled in the operating system and browser trust stores.

OS / browser trust store trusted by signs signs Leaf cert CN=localhost · 1 yr Intermediate CA "Let's Encrypt R3" Root CA trust anchor
Validating a TLS certificate is walking the chain rightward until you reach a CA already in the trust store. A self-signed cert has no chain to walk — it stops at itself, the walk fails, and the browser shows “your connection is not private.”

Two consequences fall out of this picture.

Self-signed certs fail because the chain has nowhere to terminate. A cert signed only by itself is a leaf whose signer is unknown to the trust store, so the chain walk finds no preinstalled root that vouches for it and the connection is rejected. That rejection is correct: if browsers accepted self-signed certs silently, any coffee-shop network could mint a cert for bank.com, intercept your TLS connection, and re-encrypt it transparently, defeating the entire point of HTTPS.

mkcert does something self-signing can’t. Instead of producing a leaf that signs itself, it generates a root CA that exists only on your machine, installs that root into your OS trust store (and Firefox’s separate trust store), and signs your project’s leaf cert with that root. Now the chain walks all the way up: leaf → local root → an entry the trust store recognizes → green padlock. The trust is also local: only your machine knows the local root, so no one else’s browser would trust a cert your local CA signed.

This takes two phases. First, install mkcert and its local CA on your machine; you do this once. Second, in each project, issue a project-local cert and point Next.js at it.

  1. Install mkcert. Pick the platform that matches your machine.

    Terminal window
    brew install mkcert nss

    The nss / libnss3-tools package on macOS and Linux lets mkcert write into Firefox’s separate trust store in the next step. On Windows, the installer handles this for you.

    On disk: nothing yet, only the mkcert binary on your PATH.

  2. Install the local CA, once per machine. This creates the root CA and registers it with your OS (and Firefox).

    Terminal window
    mkcert -install

    You’ll see output along these lines (paths vary by OS):

    Created a new local CA at "/Users/you/Library/Application Support/mkcert"
    The local CA is now installed in the system trust store!
    The local CA is now installed in the Firefox trust store (requires browser restart)!

    On disk: a new root CA private key and cert under the directory that mkcert -CAROOT prints, plus a trust entry in the OS keychain (macOS) / system trust store (Linux) / certificate store (Windows). Firefox installs the root separately in the same step, so restart it once afterward to pick up the change.

  3. Issue a leaf cert for the project. From the root of your Next.js project, run:

    Terminal window
    mkcert localhost 127.0.0.1 ::1

    Expected output:

    Created a new certificate valid for the following names
    - "localhost"
    - "127.0.0.1"
    - "::1"
    The certificate is at "./localhost+2.pem" and the key at "./localhost+2-key.pem"

    On disk: two new files in the project root, localhost+2.pem (the public cert) and localhost+2-key.pem (the private key).

  4. Store the certs and gitignore the key. Move both files into a certificates/ directory so they have a stable home. The course standardizes on ./certificates/ because Next.js also drops its auto-generated certs there, which leaves one directory to remember.

    • Directorycertificates/
      • localhost.pem public cert, OK to commit
      • localhost-key.pem private key, never commit
    • .gitignore
    • package.json
    • next.config.ts

    Then add one line to .gitignore so the key never lands in Git history:

    .gitignore
    certificates/*-key.pem

    On disk: the project commits the public cert but not the private key. A teammate who has already run mkcert -install has a trusted root, so they can use the committed cert without re-issuing; only the key stays per-developer.

  5. Wire the HTTPS server into package.json and start it. Keep the existing dev script, since plain HTTP is sometimes easier to debug, and add an opt-in dev:https script next to it.

    package.json
    {
    "scripts": {
    "dev": "next dev",
    "dev:https": "next dev --experimental-https --experimental-https-key ./certificates/localhost-key.pem --experimental-https-cert ./certificates/localhost.pem"
    }
    }

    Pointing Next.js at the mkcert-issued files, rather than letting it auto-generate its own, is what makes that committed cert work: every developer reads the same shared cert from one known directory.

    Now start it:

    Terminal window
    pnpm dev:https

    You should see something like:

    ▲ Next.js 16.x.x (Turbopack)
    - Local: https://localhost:3000
    - Network: https://192.168.1.42:3000
    ✓ Ready in 1.2s

    On disk: nothing further changes; the server now reads its cert and key from ./certificates/.

Open https://localhost:3000 in the browser. You should see a green padlock in the address bar and no warning interstitial.

localhost:3000
https://localhost:3000/
Connection is secure
Your information is private when sent to this site.
Certificate is valid →
Issued to: localhost Issued by: mkcert you@your-mac (Your Name) Valid from: today · 1 year
Chain: localhost mkcert local CA OS trust store ✓
1 Green padlock on the URL pill — the browser walked the chain and reached a trusted root.
2 Issued by your local mkcert CA — present in your OS trust store because mkcert -install put it there.
What success looks like — a green padlock on https://localhost:3000, no warning interstitial. Clicking the padlock opens "Connection is secure" and a "Certificate is valid" link reveals the chain terminating at your local mkcert CA.

Click the padlock once. The browser shows “Connection is secure” and a “Certificate is valid” link that opens the certificate detail panel. There you can walk the chain: the leaf cert, signed by your local mkcert CA, which the OS trust store now lists. That’s the green padlock, decoded.

These five failure modes catch most new mkcert users.

  • The “still not trusted” loop. Forgetting mkcert -install is the most common cause: the cert is signed, but by a CA the browser doesn’t know, so the connection is rejected. Run mkcert -install and restart the browser.
  • Cert valid for one hostname, browser visits another. Issuing only mkcert localhost and then opening https://127.0.0.1:3000 fails, because the SAN list omits 127.0.0.1. Always issue all three together: mkcert localhost 127.0.0.1 ::1, and re-issue when you add a hostname.
  • Browser stuck on the previous untrusted cert. Some browsers cache the rejection across reloads. After mkcert -install, hard-reload (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows or Linux) or relaunch the browser.
  • Firefox didn’t get the root. Firefox keeps its own trust store. mkcert -install writes the root into it automatically, but you must restart Firefox to pick it up.
  • Committed the key. If *-key.pem lands in Git history, rotate the local root with mkcert -uninstall && mkcert -install, re-issue every cert that root signed, and purge the leaked key from history. The .gitignore rule from step 4 is what keeps you from ever needing this.

To verify the setup, open DevTools and run window.isSecureContext in the Console. It should return true. From here the Clipboard API, Web Crypto’s subtle interface, and Secure cookies are all available.

The production side needs no work from you. Vercel, like every modern hosting platform, provisions a Let’s Encrypt cert on the first request to your domain and auto-renews it before expiry. The five minutes you just spent are the only TLS wiring this project needs.

Whether a call runs in a secure context depends on both the URL and the API. Sort each combination below.

For each URL + API combination, decide whether it runs in a secure context. Drag each item into the bucket it belongs to, then press Check.

Works Secure-context APIs are fully available.
Works (localhost exception) Secure-context API, but the http://localhost carve-out covers it.
Blocked, needs real HTTPS Throws, rejects, or silently fails.
https://app.example.comclipboard.writeText('hi')
http://localhost:3000crypto.randomUUID()
http://localhost:3000crypto.subtle.sign(...)
http://192.168.1.42:3000 (phone on LAN) → navigator.clipboard.writeText('hi')
http://localhost:3000 → set cookie Secure; HttpOnly; SameSite=Lax
https://localhost:3000 (mkcert) → register a Service Worker