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:
Securecookies are unreliable over plain HTTP. Some browsers silently drop a cookie markedSecurewhen 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
localhostand127.0.0.1. Serve to a teammate over a LAN IP like192.168.1.42:3000, or open the preview on your phone via your machine’s.localhostname, and the bypass disappears:navigator.clipboard.writeTextthrows andcrypto.subtleis 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.
The TLS 1.3 handshake in one round trip
Section titled “The TLS 1.3 handshake in one round trip”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 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.
The certificate chain and the trust store
Section titled “The certificate chain and the trust store”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.
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.
Set up mkcert and the HTTPS dev server
Section titled “Set up mkcert and the HTTPS dev server”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.
-
Install
mkcert. Pick the platform that matches your machine.Terminal window brew install mkcert nssTerminal window sudo apt install libnss3-toolscurl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"chmod +x mkcert-v*-linux-amd64sudo mv mkcert-v*-linux-amd64 /usr/local/bin/mkcertTerminal window choco install mkcert# or, if you use Scoop:scoop bucket add extrasscoop install mkcertThe
nss/libnss3-toolspackage on macOS and Linux letsmkcertwrite into Firefox’s separate trust store in the next step. On Windows, the installer handles this for you.On disk: nothing yet, only the
mkcertbinary on yourPATH. -
Install the local CA, once per machine. This creates the root CA and registers it with your OS (and Firefox).
Terminal window mkcert -installYou’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 -CAROOTprints, 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. -
Issue a leaf cert for the project. From the root of your Next.js project, run:
Terminal window mkcert localhost 127.0.0.1 ::1Expected 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) andlocalhost+2-key.pem(the private key). -
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
.gitignoreso the key never lands in Git history:.gitignore certificates/*-key.pemOn disk: the project commits the public cert but not the private key. A teammate who has already run
mkcert -installhas a trusted root, so they can use the committed cert without re-issuing; only the key stays per-developer. -
Wire the HTTPS server into
package.jsonand start it. Keep the existingdevscript, since plain HTTP is sometimes easier to debug, and add an opt-indev:httpsscript 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:httpsYou should see something like:
▲ Next.js 16.x.x (Turbopack)- Local: https://localhost:3000- Network: https://192.168.1.42:3000✓ Ready in 1.2sOn 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.
mkcert -install put it there.
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.
Pitfalls and verification
Section titled “Pitfalls and verification”These five failure modes catch most new mkcert users.
- The “still not trusted” loop. Forgetting
mkcert -installis the most common cause: the cert is signed, but by a CA the browser doesn’t know, so the connection is rejected. Runmkcert -installand restart the browser. - Cert valid for one hostname, browser visits another. Issuing only
mkcert localhostand then openinghttps://127.0.0.1:3000fails, because the SAN list omits127.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 -installwrites the root into it automatically, but you must restart Firefox to pick it up. - Committed the key. If
*-key.pemlands in Git history, rotate the local root withmkcert -uninstall && mkcert -install, re-issue every cert that root signed, and purge the leaked key from history. The.gitignorerule 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.
Check your understanding
Section titled “Check your understanding”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.
https://app.example.com → clipboard.writeText('hi')http://localhost:3000 → crypto.randomUUID()http://localhost:3000 → crypto.subtle.sign(...)http://192.168.1.42:3000 (phone on LAN) → navigator.clipboard.writeText('hi')http://localhost:3000 → set cookie Secure; HttpOnly; SameSite=Laxhttps://localhost:3000 (mkcert) → register a Service WorkerExternal resources
Section titled “External resources”The tool's GitHub repo: install instructions, supported platforms, and the security note on rootCA-key.pem.
web.dev's argument for why localhost HTTPS matters and a walk-through of the mkcert flow.
Cloudflare's plain-English explainer covering both TLS 1.2 and the TLS 1.3 1-RTT flow.
MDN's full list of features gated by isSecureContext and the exact carve-out rules for localhost.