Why you need HTTPS locally
The list of web features that refuse to work over plain HTTP keeps growing:
- Service Workers — required for PWAs, offline flows, push notifications.
SameSite=Nonecookies — you needSecuretoo, which requires HTTPS.- WebAuthn / passkeys — secure context only.
navigator.clipboard.writeText— silently fails over HTTP.- Camera, microphone, geolocation — prompts refuse without HTTPS.
- Third-party OAuth callbacks — Google, GitHub, etc. reject HTTP redirect URLs.
- CORS preflight edge cases — mixed-content rules trip on HTTP origins.
localhost itself is a special case — browsers
treat it as a secure context even over HTTP, for
historical convenience. But the moment you use a custom
domain (which you need for cookies scoped to
.company.com or for testing the actual prod
URL pattern), the special case evaporates.
Six ways, ranked
1 — A tunneling service (easiest)
Run a tunnel. You get a real HTTPS URL signed by a public CA, no cert work on your end. This is the answer when you need HTTPS because someone else needs to reach you — webhooks, clients, real phones.
tunnel21 http 3000
# → https://your-subdomain.21tunnel.app (Let's Encrypt, real cert)
ngrok http 3000
# → https://xxxx.ngrok-free.app
cloudflared tunnel --url http://localhost:3000
# → https://weird-animal.trycloudflare.com Pros: zero cert work, real trusted CA, HTTPS from first second. Works for mobile-device testing (real iPhones get the proper padlock).
Cons: only solves the public case. If you're testing locally on your own browser without needing external reach, this is overkill. URL changes on free tiers unless you reserve a subdomain.
2 — mkcert (best for browser testing)
mkcert creates a local Certificate Authority,
installs it in your OS trust store, and issues certs for
anything you want. Your browser trusts them instantly; no
warnings, no tunneling.
brew install mkcert # macOS
mkcert -install # one-time: install local root
mkcert localhost 127.0.0.1 ::1 app.local
# → writes localhost+3.pem + localhost+3-key.pem Point your dev server at the cert files:
# Node HTTPS server
import https from "https";
import fs from "fs";
https.createServer({
key: fs.readFileSync("localhost+3-key.pem"),
cert: fs.readFileSync("localhost+3.pem"),
}, app).listen(3000); Pros: no browser warnings, works offline, no external dependency. The gold standard for local dev HTTPS.
Cons: only your machines trust the cert. Teammates must install their own CA. Phones won't trust it without installing the mkcert root on the device, which is fiddly on iOS.
3 — Caddy, local mode
Caddy is a reverse proxy that auto-provisions certs.
Point it at localhost:3000, give it a
domain, and it handles TLS termination.
# Caddyfile
localhost {
reverse_proxy localhost:3000
}
caddy run
# Now https://localhost/ proxies to your dev server with a valid cert.
For custom domains (dev.mycompany.local),
Caddy runs its own local CA, same pattern as mkcert —
but as a daemon, with auto-renewal, which is more
ergonomic for long-lived dev setups.
Pros: one tool does TLS + reverse proxy + virtual hosts. Config is tiny.
Cons: you're running a separate process. Overkill for “I just want HTTPS on port 3000 for five minutes.”
4 — Let's Encrypt via DNS-01 challenge
Want a real publicly-valid cert for a domain
that points at 127.0.0.1? Use Let's
Encrypt's DNS-01 challenge. You prove domain
ownership by adding a TXT record; you never expose any
public port.
# With certbot + cloudflare plugin
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials ~/cloudflare.ini \
-d dev.yourdomain.com
# Then pin dev.yourdomain.com → 127.0.0.1 in /etc/hosts Pros: real cert, trusted by every browser and every OS, without anyone being able to reach your box from outside. Works for teammates (if they also put the domain in their /etc/hosts).
Cons: you need a domain you own and DNS API access. Cert expires every 90 days, renewal is your problem.
5 — Framework dev certs
Most modern frameworks ship a --https flag
that generates a self-signed cert for you. Convenient but
the browser warning gets old.
# Vite
vite --https
# Next.js (experimental flag)
next dev --experimental-https
# Rails (with some coaxing)
rails server -b "ssl://localhost:3000?cert=./cert.pem&key=./key.pem"
# .NET / dotnet
dotnet dev-certs https --trust
.NET is the exception — dotnet dev-certs
installs a trusted cert in your OS store, essentially
doing what mkcert does automatically. The others generate
untrusted self-signed certs you'll click past.
Pros: one flag. No extra tools. Works for quick local testing.
Cons: browser shows a warning every time on most frameworks. Third-party OAuth callbacks usually refuse self-signed destinations.
6 — Old-school self-signed
The manual version of option 5.
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
-sha256 -days 365 -nodes -subj "/CN=localhost" Pros: works everywhere, no tools to install.
Cons: every browser complains, every client complains, every teammate has to click past the same warning. In 2026 there's essentially no reason to do this instead of mkcert.
How to pick one
Decision tree:
- Do external systems need to reach you? (webhooks, mobile devices, OAuth callbacks, teammates over the internet) → tunneling service.
- Is this just your machine, your browser? → mkcert. It's the cleanest dev-only experience.
- Your whole team needs to hit the same dev
domain, on your office LAN? → Let's
Encrypt DNS-01 for a publicly-valid cert + a
shared
/etc/hostsentry. Everyone gets browser-trusted HTTPS, your box stays internal-only. - You already have reverse-proxy needs (multiple services on different subdomains) → Caddy. TLS comes along for free.
- You're sanity-checking one feature and
will click past the warning → framework's
--httpsflag.
Three gotchas that waste hours
Every one of these has eaten an afternoon of mine.
- HSTS caching. If you ever visited
https://app.mycompany.comin production, your browser may have cached an HSTS policy that forbids HTTP to that domain. When you try to test locally againsthttp://app.mycompany.com, the browser upgrades it to HTTPS silently and the connection fails. Fix: clear HSTS for the domain inchrome://net-internals/#hsts. - Certificate hostnames. A cert for
localhostdoes not cover127.0.0.1, and a cert forexample.comdoes not coverwww.example.com. Always list every name you'll access in the SAN. With mkcert, this meansmkcert localhost 127.0.0.1 ::1 app.local— all four. - Mixed content. An HTTPS page that
tries to load
http://resources is silently broken in the browser dev tools. A common flavor: your dev server serves HTTPS, but the API client hardcodeshttp://localhost:3001somewhere. The fetch fails with a CORS-ish error that actually has nothing to do with CORS.
Short version: if a real browser on another device needs to reach you, use a tunneling service. If it's just your own Chrome talking to your own laptop, use mkcert. Everything else is a special case.
For tunneling: 21tunnel serves HTTPS by default on every tunnel (Let's Encrypt on our side), free on Hobby tier, custom domain on signup. Compare to other tunneling tools in our roundup.