The problem in one sentence
Most modern browser features (Service Workers, push notifications, Web USB, secure cookies, Web Authentication, geolocation, modern OAuth flows) require HTTPS, and Let's Encrypt does not issue certs for localhost or private IPs because the ACME challenge can't reach them.
You hit this fast. Your dev server runs on http://localhost:3000. You add navigator.serviceWorker.register(...) — silently no-op. You set a cookie with Secure; SameSite=None for cross-origin — silently dropped. You try the WebAuthn flow — SecurityError: The operation is insecure.
There are three working ways out.
Approach 1: mkcert (local CA)
mkcert creates a per-machine Certificate Authority, installs it into your OS trust store, and issues cert/key pairs for any domain you ask for — including localhost and 127.0.0.1. Your browser trusts the CA, so the cert is valid as far as the browser knows. Other machines don't trust the CA, so the cert only works on yours.
$ brew install mkcert
$ mkcert -install
$ mkcert localhost 127.0.0.1 ::1
# produces localhost+2.pem and localhost+2-key.pem
Run your server with the produced cert/key:
import https from 'https'
import fs from 'fs'
https.createServer({
key: fs.readFileSync('./localhost+2-key.pem'),
cert: fs.readFileSync('./localhost+2.pem'),
}, app).listen(3000)
This works for solo dev with one machine. It does not work for testing on a phone, on a colleague's laptop, or for OAuth providers (which won't redirect to localhost over HTTPS even if your browser would accept it).
Approach 2: localhost is a "secure context" anyway
Browsers special-case http://localhost and http://127.0.0.1 — those origins are treated as secure contexts even without HTTPS. So Service Workers, secure cookies (with the Secure flag dropped), getUserMedia, and most modern APIs do work on plain http://localhost.
This is the lightest-weight option for solo dev: just use http://localhost and most things work. It breaks down at exactly the point where your testing leaves the localhost origin — phone-on-WiFi, OAuth callbacks, sharing a preview URL.
Approach 3: dev tunnel with a real cert
A reverse-tunnel service that issues real Let's Encrypt certs for the public hostname punches all the way through. Your local dev server speaks plain HTTP; the tunnel terminates HTTPS at its edge.
$ lrok http 3000 --hint dev
Forwarding https://dev.lrok.io -> http://127.0.0.1:3000
Now https://dev.lrok.io is a real public URL with a real cert. Browsers, mobile devices, OAuth providers, push-notification services — all of them accept it as legitimate HTTPS.
This is the only option that works for:
- Testing on a real iPhone / Android device against your laptop
- Receiving Stripe / GitHub webhooks (they require HTTPS, will not POST to localhost)
- OAuth flows where the provider validates the redirect URI's TLS
- WebAuthn / Passkey testing across devices
- Service workers in cross-device debugging
- Sharing a working preview with someone in another office
When to use which
| Situation | Best fit |
|---|---|
| Just my laptop, solo dev | http://localhost (secure context exemption) |
| Multiple browsers / Electron / mobile sim | mkcert |
| Stripe/GitHub webhook testing | dev tunnel |
| Real iPhone / Android testing | dev tunnel |
| OAuth callback dev | dev tunnel |
| Preview share with a colleague | dev tunnel |
| WebAuthn / Passkeys | dev tunnel |
In practice most teams settle on a mix: localhost-secure-context for the inner loop, dev tunnel for everything that crosses the network boundary.
What lrok specifically does
The lrok edge issues a Let's Encrypt cert per reserved subdomain (and for any custom domain you bring with TXT-record verification). The cert is real — Mozilla, Apple, Google, browsers on Android, browsers on iOS all trust it. There's no certificate-pinning surprise. There's no manual cert-install on each device.
Free plan reserves one subdomain for life. Pro adds custom domains. Either way, the HTTPS just works.