Published
- 33 min read
Setting Up HTTPS for Local Development
How to Write, Ship, and Maintain Code Without Shipping Vulnerabilities
A hands-on security guide for developers and IT professionals who ship real software. Build, deploy, and maintain secure systems without slowing down or drowning in theory.
Buy the book now
Practical Digital Survival for Whistleblowers, Journalists, and Activists
A practical guide to digital anonymity for people who can’t afford to be identified. Designed for whistleblowers, journalists, and activists operating under real-world risk.
Buy the book now
The Digital Fortress: How to Stay Safe Online
A simple, no-jargon guide to protecting your digital life from everyday threats. Learn how to secure your accounts, devices, and privacy with practical steps anyone can follow.
Buy the book nowIntroduction
In modern web development, HTTPS is essential for securing data in transit, protecting user privacy, and ensuring trust. While HTTPS is standard in production environments, setting it up for local development often gets overlooked. By configuring HTTPS locally, developers can simulate secure environments, test SSL-specific features, and catch potential issues early.
This guide provides a step-by-step approach to setting up HTTPS for local development using self-signed certificates, tools, and best practices.
Why Use HTTPS in Local Development?
1. Simulating Real-World Scenarios
- Testing applications in a secure environment ensures that they work seamlessly in production.
2. Catching SSL/TLS Issues Early
- Identifying HTTPS-related problems during development saves time and reduces deployment risks.
3. Enabling Secure APIs
- Many APIs require HTTPS, even in development, to accept requests.
4. Compliance Testing
- Some features, like browser-based geolocation, require HTTPS to function.
Prerequisites
Tools and Libraries Needed:
- OpenSSL: For generating self-signed certificates.
- Node.js or Python: To run local servers with HTTPS.
- A Web Browser: For testing HTTPS configurations.
Generating a Self-Signed Certificate
Step 1: Install OpenSSL
Install OpenSSL if it’s not already available on your system.
- For macOS: OpenSSL is included. If not, use Homebrew:
brew install openssl. - For Windows: Download the installer from openssl.org.
- For Linux: Use your package manager, e.g.,
sudo apt-get install openssl.
Step 2: Create a Certificate and Private Key
Run the following OpenSSL commands to generate a certificate and private key:
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout local-dev.key -out local-dev.crt
Explanation:
-x509: Creates a self-signed certificate.-nodes: Prevents encrypting the private key.-days 365: Specifies the certificate’s validity period.-newkey rsa:2048: Generates a new RSA key with 2048 bits.
When prompted, provide the following details (use dummy values for local development):
- Country Name:
US - State:
California - Locality:
San Francisco - Organization:
LocalDev - Common Name:
localhost
This generates two files:
local-dev.key: The private key.local-dev.crt: The self-signed certificate.
Step 3: Configure the Development Server
Using Node.js
- Install dependencies:
npm install express https fs
- Create an HTTPS server:
const https = require('https')
const fs = require('fs')
const express = require('express')
const app = express()
const options = {
key: fs.readFileSync('local-dev.key'),
cert: fs.readFileSync('local-dev.crt')
}
app.get('/', (req, res) => {
res.send('Hello, HTTPS World!')
})
https.createServer(options, app).listen(3000, () => {
console.log('HTTPS Server running on https://localhost:3000')
})
- Run the server:
node server.js
Visit https://localhost:3000 in your browser. You’ll see a security warning because the certificate isn’t signed by a trusted authority.
Using Python
- Create a self-signed certificate with OpenSSL.
- Start a Python HTTPS server:
python -m http.server --bind localhost --certfile local-dev.crt --keyfile local-dev.key 8443
Visit https://localhost:8443 in your browser.
Handling Self-Signed Certificate Warnings
Option 1: Add the Certificate to Trusted Authorities
- Add the
local-dev.crtto your operating system’s trusted root certificates. - macOS: Use Keychain Access.
- Windows: Use the Certificates MMC snap-in.
- Linux: Add it to
/etc/ssl/certs/.
Option 2: Bypass Warnings (Development Only)
- Manually accept the certificate warning in your browser.
Best Practices for HTTPS in Local Development
- Automate Certificate Generation
- Use tools like
mkcertto simplify the process of creating local certificates.
- Enable HSTS
- Simulate production behavior by enabling HTTP Strict Transport Security (HSTS).
- Redirect HTTP to HTTPS
- Use middleware to redirect HTTP requests to HTTPS.
Example (Node.js):
app.use((req, res, next) => {
if (!req.secure) {
return res.redirect(`https://${req.headers.host}${req.url}`)
}
next()
})
- Use Secure Cookies
- Mark cookies as
Secureto ensure they are only sent over HTTPS.
- Monitor for SSL/TLS Issues
- Use browser developer tools to identify insecure resources and mixed content.
Troubleshooting Common Issues
1. Browser Shows “Connection Not Secure”
- Ensure the self-signed certificate is added to trusted authorities.
2. Server Fails to Start
- Verify the paths to
local-dev.keyandlocal-dev.crt. - Check if the port (e.g.,
3000or8443) is already in use.
3. Mixed Content Warnings
- Ensure all assets (e.g., CSS, JS, images) are served over HTTPS.
Using mkcert: The Modern Approach to Local HTTPS
While OpenSSL works, it produces certificates that your browser does not automatically trust—resulting in the intimidating “Your connection is not private” warning every time you open your dev server. mkcert eliminates this entirely by creating a local certificate authority (CA) and registering it with your operating system’s trust store. Chrome, Firefox, Safari, and every OS-level HTTP client then trust it without any manual override.
This makes mkcert the recommended tool for local HTTPS in 2024 and beyond. It is free, open source, cross-platform, and requires zero configuration.
Installing mkcert
macOS:
brew install mkcert
brew install nss # Required for Firefox support
Windows (Chocolatey):
choco install mkcert
Windows (Scoop):
scoop bucket add extras
scoop install mkcert
Linux (Ubuntu / Debian):
sudo apt install libnss3-tools
curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
Linux (Arch):
sudo pacman -Syu mkcert
Creating Trusted Certificates with mkcert
The workflow is two commands:
Step 1 — Install the local CA (run only once per machine):
mkcert -install
This creates a local root CA and registers it with your OS and browsers simultaneously. On macOS and Windows you will be prompted for your administrator password. After this single step, all certificates issued by mkcert will be unconditionally trusted on your machine.
Step 2 — Generate a certificate for your project:
# Navigate to your project root first
cd my-project
mkcert localhost 127.0.0.1 ::1
This produces two files:
localhost+2.pem— the certificate (public)localhost+2-key.pem— the private key (keep this secret)
You can include as many hostnames and IP addresses as you need in a single certificate:
mkcert myapp.local "*.myapp.local" localhost 127.0.0.1 ::1
To generate the files with predictable names, use the -cert-file and -key-file flags:
mkcert -cert-file cert.pem -key-file key.pem localhost 127.0.0.1
Protecting the Root CA Key
The file rootCA-key.pem (stored in the directory printed by mkcert -CAROOT) is highly sensitive. Anyone who possesses it can forge trusted certificates for any domain on any machine that trusts the corresponding public CA—including your bank, your email provider, or your company’s internal services.
# Find the CA root directory
mkcert -CAROOT
# Example output: /Users/you/Library/Application Support/mkcert
Best practices for protecting it:
- Never commit it to version control (add
*.pemandcerts/to.gitignore) - Never share it with teammates—each developer must run
mkcert -installindependently - Never copy it to a shared machine or cloud storage
Node.js and the System Trust Store
Node.js does not read the operating system trust store. Even after running mkcert -install, Node-based tools—node-fetch, axios, jest test runners hitting local APIs—will reject mkcert certificates with UNABLE_TO_VERIFY_LEAF_SIGNATURE.
The fix is the NODE_EXTRA_CA_CERTS environment variable:
# Bash / Zsh — add to ~/.bashrc or ~/.zshrc
export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"
# PowerShell — add to $PROFILE
$env:NODE_EXTRA_CA_CERTS = "$(mkcert -CAROOT)\rootCA.pem"
Restart your terminal after making this change so that all new shells inherit the variable.
Setting Up HTTPS in Different Development Environments
Node.js with Express (Full Example)
The earlier section showed the basics. Here is a production-realistic setup with HTTP-to-HTTPS redirection and security headers:
// server.js
const https = require('https')
const http = require('http')
const fs = require('fs')
const express = require('express')
const app = express()
// Redirect plain HTTP to HTTPS
const redirectApp = express()
redirectApp.use((req, res) => {
const httpsUrl = `https://${req.headers.host.split(':')[0]}:3443${req.url}`
res.redirect(301, httpsUrl)
})
// Security headers for every response
app.use((_req, res, next) => {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'DENY')
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
next()
})
app.get('/', (_req, res) => {
res.send('Secure Hello from Express!')
})
const httpsOptions = {
key: fs.readFileSync('cert.pem').toString() === '' ? null : fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem')
}
// Actually replace with:
const tlsOptions = {
key: fs.readFileSync('./key.pem'),
cert: fs.readFileSync('./cert.pem')
}
http.createServer(redirectApp).listen(3000, () => console.log('HTTP → redirect on :3000'))
https
.createServer(tlsOptions, app)
.listen(3443, () => console.log('HTTPS server → https://localhost:3443'))
Run with:
npm install express
node server.js
Python with Flask
Flask supports TLS natively via Python’s ssl module:
# app.py
import ssl
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
return 'Secure Flask server running!'
if __name__ == '__main__':
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain('cert.pem', 'key.pem')
app.run(host='0.0.0.0', port=5443, ssl_context=context)
Run with:
pip install flask
python app.py
# Visit https://localhost:5443
For quick prototyping, Flask also accepts a shorthand tuple:
app.run(ssl_context=('cert.pem', 'key.pem'), port=5443)
Python with Django
The django-extensions package provides runserver_plus, which wraps Werkzeug’s development server and adds TLS support:
pip install django-extensions werkzeug pyOpenSSL
Add to settings.py:
INSTALLED_APPS = [
# ...
'django_extensions',
]
Start the secure server:
python manage.py runserver_plus --cert-file cert.pem --key-file key.pem 0.0.0.0:8443
Configuring HTTPS in Docker
Running HTTPS inside Docker requires mounting your certificates into the container and configuring the server to use them. The most flexible approach is to run Nginx as a TLS-terminating reverse proxy in front of your application container.
Project Structure
my-project/
├── certs/
│ ├── cert.pem
│ └── key.pem
├── nginx/
│ └── default.conf
├── app/
│ └── index.js
└── docker-compose.yml
Nginx Configuration
# nginx/default.conf
server {
listen 80;
server_name localhost;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name localhost;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
# Enforce modern TLS only
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Docker Compose
# docker-compose.yml
version: '3.8'
services:
app:
build: ./app
expose:
- '3000'
environment:
- NODE_ENV=development
nginx:
image: nginx:alpine
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- app
Start everything with:
docker compose up
Visit https://localhost. Nginx handles TLS termination and forwards plain HTTP internally to the app container—a pattern that mirrors how most production deployments (load balancer → app servers) work.
How a Request Flows Through the Docker Stack
sequenceDiagram
participant B as Browser
participant N as Nginx :443
participant A as Node.js App :3000
B->>N: HTTPS GET / (TLS ClientHello)
N-->>B: ServerHello + Certificate
B->>N: Encrypted HTTP/1.1 request
N->>A: Plain HTTP proxy_pass
A-->>N: HTTP 200 response
N-->>B: Encrypted HTTP/1.1 response
Using Caddy as a Zero-Config TLS Reverse Proxy
Caddy is a modern web server that provides automatic HTTPS. For local development with a custom hostname, add an entry to your hosts file and create a Caddyfile:
# /etc/hosts — add this line
127.0.0.1 myapp.local
# Caddyfile
myapp.local {
tls /path/to/cert.pem /path/to/key.pem
reverse_proxy localhost:3000
}
Run:
caddy run
Caddy also supports automatic certificate management via its internal CA (tls internal), which behaves similarly to mkcert for local hostnames.
Configuring HTTPS with Vite and Create React App
Vite
Vite has native HTTPS support. You can use the official @vitejs/plugin-basic-ssl for a zero-config quick start, or point Vite directly at mkcert certificates for fully trusted TLS.
Option A — Basic SSL plugin (self-signed, triggers browser warnings):
npm install -D @vitejs/plugin-basic-ssl
// vite.config.js
import { defineConfig } from 'vite'
import basicSsl from '@vitejs/plugin-basic-ssl'
export default defineConfig({
plugins: [basicSsl()],
server: {
https: true,
port: 5173
}
})
Option B — mkcert certificates (trusted, recommended):
# Generate certificates in the project root
mkcert -cert-file cert.pem -key-file key.pem localhost 127.0.0.1
// vite.config.js
import { defineConfig } from 'vite'
import fs from 'fs'
export default defineConfig({
server: {
https: {
key: fs.readFileSync('./key.pem'),
cert: fs.readFileSync('./cert.pem')
},
port: 5173
}
})
Option C — vite-plugin-mkcert (fully automated):
npm install -D vite-plugin-mkcert
// vite.config.js
import { defineConfig } from 'vite'
import mkcert from 'vite-plugin-mkcert'
export default defineConfig({
plugins: [mkcert()],
server: {
https: true
}
})
This plugin automatically installs the mkcert CA and regenerates certificates on first run. It is the most developer-friendly option for Vite projects.
Create React App
Create React App reads HTTPS configuration from environment variables. Create a .env.local file in the project root:
# .env.local
HTTPS=true
SSL_CRT_FILE=./cert.pem
SSL_KEY_FILE=./key.pem
Or pass the variables directly in package.json:
{
"scripts": {
"start:https": "HTTPS=true SSL_CRT_FILE=cert.pem SSL_KEY_FILE=key.pem react-scripts start"
}
}
Windows (PowerShell / cmd):
{
"scripts": {
"start:https": "set HTTPS=true&& set SSL_CRT_FILE=cert.pem&& set SSL_KEY_FILE=key.pem&& react-scripts start"
}
}
Next.js
For Next.js 13.5 and later, the --experimental-https flag spins up a locally trusted HTTPS server in one command:
next dev --experimental-https
For full control, create a custom HTTPS server:
// server.js
const { createServer } = require('https')
const { parse } = require('url')
const next = require('next')
const fs = require('fs')
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
const tlsOptions = {
key: fs.readFileSync('./key.pem'),
cert: fs.readFileSync('./cert.pem')
}
app.prepare().then(() => {
createServer(tlsOptions, (req, res) => {
const parsedUrl = parse(req.url, true)
handle(req, res, parsedUrl)
}).listen(3443, () => {
console.log('Next.js ready → https://localhost:3443')
})
})
Run with:
node server.js
Tool Comparison: mkcert vs OpenSSL vs Caddy
Choosing the right tool depends on your workflow, operating system diversity, and how much control you need over certificate attributes.
| Feature | mkcert | OpenSSL | Caddy |
|---|---|---|---|
| Trust store integration | Automatic (OS + browsers) | Manual | Automatic (ACME / local CA) |
| Ease of setup | Very easy (2 commands) | Complex (many flags) | Easy (Caddyfile syntax) |
| Wildcard certificates | Yes ("*.app.local") | Yes (requires SAN config) | Yes |
| Custom hostnames | Yes | Yes | Yes |
| Works fully offline | Yes | Yes | Yes (local CA mode) |
| Built-in reverse proxy | No | No | Yes |
| HTTP/2 support | Depends on server | Depends on server | Yes, automatic |
| Production use | No (dev only) | Yes | Yes |
| Cross-platform | macOS, Linux, Windows | macOS, Linux, Windows | macOS, Linux, Windows |
| Learning curve | Low | High | Medium |
| Certificate renewal | Manual (re-run mkcert) | Manual | Automatic |
| Client certificates (mTLS) | Limited | Full support | Full support |
When to Choose Each Tool
Choose mkcert when:
- You want the simplest, fastest developer experience with zero browser warnings
- Your team spans macOS, Linux, and Windows and you need a consistent setup
- You only need trusted server certificates for existing local servers
Choose OpenSSL when:
- You need full control over certificate extensions and attributes
- You are generating client certificates for mutual TLS (mTLS) scenarios
- You need intermediate CA chains, PKCS#12 bundles, or specific cipher configurations
- You are scripting certificate workflows in CI/CD pipelines
Choose Caddy when:
- You want TLS termination, HTTP/2, and reverse proxying in a single binary
- You want automatic certificate renewal in a local staging environment
- You are already using Caddy in production and want parity locally
How the TLS Handshake Works in Local Development
Understanding the handshake at a high level makes troubleshooting significantly easier. When your browser first connects to https://localhost:3443, the following sequence occurs before any application data is exchanged:
sequenceDiagram
participant C as Client (Browser)
participant S as Server (localhost:3443)
C->>S: ClientHello (supported TLS versions, cipher suites, random nonce)
S-->>C: ServerHello (chosen cipher suite, session ID, random nonce)
S-->>C: Certificate (cert.pem containing the public key)
S-->>C: ServerHelloDone
C->>C: Verify certificate chain against trusted CAs
C->>S: ClientKeyExchange (pre-master secret, encrypted with server public key)
C->>S: ChangeCipherSpec (switching to symmetric encryption)
C->>S: Finished (hash of entire handshake)
S-->>C: ChangeCipherSpec
S-->>C: Finished
Note over C,S: Secure tunnel established
C->>S: Encrypted application data (HTTP request)
S-->>C: Encrypted application data (HTTP response)
The critical step is “Verify certificate chain against trusted CAs.” When using a raw self-signed certificate, the browser cannot find the issuer in its trust store and aborts with ERR_CERT_AUTHORITY_INVALID. When using mkcert, the browser walks up the certificate chain, finds the mkcert root CA registered in the OS trust store, and completes the handshake silently.
What the Browser Validates
- Validity period: Is today between
notBeforeandnotAfterin the certificate? - Hostname match: Does a Subject Alternative Name (SAN) DNS entry match
localhost? - Issuer trust: Is the certificate’s issuer CA in the browser’s trusted root program?
- Key usage: Does the certificate allow server authentication (
extendedKeyUsage: serverAuth)?
A common mistake with hand-crafted OpenSSL certificates is omitting the SAN extension. Chrome 58+ ignores the Common Name field for hostname validation and requires a matching SAN entry. To generate a SAN-enabled self-signed certificate with OpenSSL, use a config file:
# san.cnf
[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = localhost
[v3_req]
keyUsage = keyEncipherment, dataEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = myapp.local
IP.1 = 127.0.0.1
IP.2 = ::1
Generate:
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
-keyout local-dev.key -out local-dev.crt \
-config san.cnf -extensions v3_req
The 825-day limit is the maximum validity period Chrome will accept for leaf certificates.
Testing Your Local HTTPS Setup
After configuring HTTPS, verify the setup is correct before writing any application code against it.
Using curl
curl is the quickest way to probe your HTTPS server from the command line:
# Full verification — passes for mkcert certs once CA is installed
curl -v https://localhost:3443
# Skip certificate validation — useful for raw self-signed certs (dev only)
curl --insecure https://localhost:3443
# Explicitly specify a trusted CA file
curl --cacert "$(mkcert -CAROOT)/rootCA.pem" https://localhost:3443
In the verbose output, look for:
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* Server certificate:
* subject: CN=localhost
* issuer: CN=mkcert ...
* SSL certificate verify ok.
SSL certificate verify ok confirms the certificate is trusted.
Using OpenSSL s_client
For deeper certificate inspection:
openssl s_client -connect localhost:3443 -servername localhost
The output includes:
- The full certificate chain (look for
Certificate chainsection) - The negotiated cipher suite
- The certificate’s
notBefore/notAftervalidity window Verify return code: 0 (ok)for a successful chain verification
If you see return code 18 (self-signed certificate) or 20 (unable to get local issuer certificate), the CA is not trusted.
Browser DevTools
Open DevTools (F12) → Security tab. A correctly configured HTTPS connection shows:
- “This page is secure (valid HTTPS)”
- The certificate issuer (should be
mkcert <hostname>@<machine>for mkcert certificates) - The TLS version and cipher suite in use
The Console tab will surface mixed content warnings if any resources load over plain HTTP while the parent page is served over HTTPS.
Automated HTTPS Check with Node.js
// check-https.js
const https = require('https')
const { execSync } = require('child_process')
// Tell Node.js to trust the mkcert CA
process.env.NODE_EXTRA_CA_CERTS = `${execSync('mkcert -CAROOT').toString().trim()}/rootCA.pem`
const req = https.request(
{ hostname: 'localhost', port: 3443, path: '/', method: 'GET' },
(res) => {
console.log('Status :', res.statusCode)
console.log('TLS authorized :', res.socket.authorized)
console.log('Cipher :', res.socket.getCipher().name)
console.log('TLS version :', res.socket.getProtocol())
}
)
req.on('error', (err) => console.error('Error:', err.message))
req.end()
Run with node check-https.js. A healthy output looks like:
Status : 200
TLS authorized : true
Cipher : TLS_AES_256_GCM_SHA384
TLS version : TLSv1.3
Advanced Troubleshooting for HTTPS Issues
ERR_CERT_AUTHORITY_INVALID
The browser cannot find the issuer CA in its trust store.
Common causes and fixes:
| Cause | Fix |
|---|---|
| mkcert CA not installed | Run mkcert -install |
| Firefox on Linux uses NSS | Install libnss3-tools, then mkcert -install |
| Firefox needs a restart | Close all Firefox windows and reopen |
| Upgraded mkcert, new CA generated | Run mkcert -install again |
| Different user account | Run mkcert -install as the same user running the browser |
ERR_CERT_COMMON_NAME_INVALID
The certificate was not issued for the hostname the browser is accessing.
Fix: Regenerate the certificate including every hostname you will use:
mkcert localhost 127.0.0.1 myapp.local "*.myapp.local"
Update your server configuration to point at the newly generated files, then restart the server.
Certificate Trusted in Browser but Not in Node.js
Node.js does not read the OS trust store. Set the CA path:
export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"
Then restart any running Node processes.
Mixed Content Blocking
Mixed content occurs when a secure page loads sub-resources over HTTP. Browsers block passive mixed content and log an error for active mixed content.
Common culprits and fixes:
- Hardcoded
http://URLs in source: do a global search forhttp://localhostandhttp://127.0.0.1 - WebSocket connections: change
ws://towss:// - fetch / axios base URLs: update to
https:// <img>,<script>,<link>src attributes: ensure none point to HTTP origins
Port Already in Use
# macOS / Linux — find the process using port 3443
lsof -i :3443
# Windows — list all processes and their ports
netstat -ano | findstr :3443
# Kill by PID
taskkill /PID <pid> /F
Expired Certificates
Both mkcert and OpenSSL certificates have a finite validity period:
# Check expiry date of any certificate
openssl x509 -in cert.pem -noout -dates
# notBefore=Aug 22 09:00:00 2024 GMT
# notAfter =Aug 22 09:00:00 2025 GMT
When a certificate expires, regenerate it:
# mkcert
mkcert -cert-file cert.pem -key-file key.pem localhost 127.0.0.1
# OpenSSL
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
-keyout local-dev.key -out local-dev.crt -config san.cnf -extensions v3_req
Set a calendar reminder ahead of the expiry date, or automate regeneration in a predev npm script.
Common Mistakes and Anti-Patterns
1. Committing Certificate Files to Version Control
Private keys in a repository are a serious security risk. Even if you delete the file in a later commit, it remains accessible in git history.
Add the following entries to your .gitignore:
# TLS certificates and keys — never commit these
*.pem
*.key
*.crt
*.p12
*.pfx
certs/
.cert/
Use a tool like git-secrets or a pre-commit hook to scan for accidentally staged key files.
2. Using Production Certificates in Development
Copying a real Let’s Encrypt certificate into your dev environment exposes the private key if the repository is shared. Always generate dedicated development certificates. They cost nothing with mkcert and carry zero production risk.
3. Using --insecure / -k Flags Without Removal Strategy
It is tempting to silence TLS errors with:
curl --insecure https://localhost:3443
The danger is that these flags spread into shared scripts and CI pipelines and eventually reach staging or production probes, masking real certificate errors. Instead, install the mkcert CA once and remove the insecure flag permanently.
4. Omitting Subject Alternative Names (SANs)
Hand-crafted OpenSSL certificates that rely solely on the Common Name field fail in Chrome 58+ with ERR_CERT_COMMON_NAME_INVALID. Always include at least one SAN entry as shown in the OpenSSL SAN config example above.
5. Not Mirroring Production TLS Configuration
If production enforces TLS 1.2 with specific cipher suites, configure your local server identically. Mismatches can hide compatibility bugs—for example, a client that works locally but fails in staging because it used a TLS 1.3 feature not available in the production load balancer.
6. Forgetting to Set NODE_EXTRA_CA_CERTS for Server-Side Tests
Unit and integration tests that make HTTPS requests to local servers will fail with UNABLE_TO_VERIFY_LEAF_SIGNATURE unless NODE_EXTRA_CA_CERTS is set. Add the export to your .env.test or your test setup file:
// jest.setup.js or vitest.setup.js
const { execSync } = require('child_process')
process.env.NODE_EXTRA_CA_CERTS = `${execSync('mkcert -CAROOT').toString().trim()}/rootCA.pem`
7. Running mkcert on a Shared or CI Machine as a Long-Term Solution
mkcert is designed for individual developer workstations. Running mkcert -install on a shared build server installs a CA that every user on that machine trusts, which is an unnecessary security risk. For CI pipelines, use curl --cacert with the mkcert root CA file, or configure the test environment to skip TLS verification in a controlled and documented way.
Using Custom Local Hostnames
By default, HTTPS certificates for localhost and 127.0.0.1 are sufficient for most development tasks. However, there are compelling reasons to use custom local hostnames—names like myapp.local, api.myapp.local, or auth.myapp.local—instead of bare localhost.
Why Custom Hostnames Matter
The most important reason is cookie isolation. If you are running multiple applications on different ports of localhost, they all share the same cookie jar. A session cookie set by your API server at localhost:4000 is visible to your frontend at localhost:3000, and also to any other service running on localhost. This is not how production behaves, where each subdomain is isolated. Using custom hostnames such as api.myapp.local and app.myapp.local gives each service its own cookie namespace, making development behaviour faithful to production.
A second reason is OAuth redirect URIs. Many OAuth providers—Google, GitHub, Microsoft—maintain a list of allowed redirect URIs and validate that the redirect URI in any given OAuth flow exactly matches a registered entry. Registering https://myapp.local:3000/callback in your OAuth application is often more reliable than trying to register a localhost URI with a non-standard port, which some providers reject or flag.
A third reason is that some browser security features are gated on origin matching. The SameSite=Strict cookie policy, for instance, treats cross-port requests on localhost as same-origin, which can obscure cross-site request forgery (CSRF) bugs that would surface in production.
Configuring /etc/hosts
The /etc/hosts file provides a machine-local DNS override. Any hostname entry here resolves before the system hits a real DNS server. To create myapp.local:
# macOS / Linux — open with sudo to allow writes
sudo nano /etc/hosts
# Windows — open Notepad as Administrator, then open:
# C:\Windows\System32\drivers\etc\hosts
Add the following line:
127.0.0.1 myapp.local
127.0.0.1 api.myapp.local
127.0.0.1 auth.myapp.local
After saving, verify the entry resolves:
ping myapp.local
# Should show 127.0.0.1
Generating Certificates for Custom Hostnames
With mkcert:
mkcert -cert-file cert.pem -key-file key.pem myapp.local "*.myapp.local" localhost 127.0.0.1
The wildcard "*.myapp.local" covers any single-level subdomain—api.myapp.local, auth.myapp.local, etc.—without needing a separate certificate for each.
Automating Hosts File Management
Editing /etc/hosts manually quickly becomes tedious on large projects. The hostile npm package provides a simple CLI:
npm install -g hostile
# Add entries
hostile set 127.0.0.1 myapp.local
hostile set 127.0.0.1 api.myapp.local
# List current entries
hostile list
# Remove an entry
hostile remove myapp.local
Alternatively, dnsmasq can be configured to resolve entire TLDs locally. For example, routing all *.local hostnames to 127.0.0.1 without editing /etc/hosts for each new service. This is particularly useful on macOS where dnsmasq integrates cleanly with the system resolver.
Once custom hostnames are configured, your browser, curl, and all Node.js clients will resolve them to your local machine. As long as the TLS certificate includes those hostnames in its SAN list and the mkcert CA is installed, HTTPS works seamlessly with no warnings.
Certificate Lifecycle and Renewal Planning
Certificates issued by mkcert have a two-year validity period by default. OpenSSL self-signed certificates created with -days 365 expire after one year. Letting a certificate expire in a shared development environment can halt work for everyone on the team without warning—browsers start refusing connections and error messages can be confusing if developers are not aware of the root cause.
Checking Expiry Programmatically
You can incorporate an expiry check into your project’s start script so developers are warned before the certificate expires rather than after it does. Here is a small Node.js helper:
// scripts/check-cert.js
const { execSync } = require('child_process')
const fs = require('fs')
const certFile = process.env.SSL_CRT_FILE || './cert.pem'
if (!fs.existsSync(certFile)) {
console.warn(
`[TLS] Certificate not found at ${certFile}. Run: mkcert -cert-file cert.pem -key-file key.pem localhost`
)
process.exit(0)
}
const output = execSync(`openssl x509 -in ${certFile} -noout -enddate`).toString()
const match = output.match(/notAfter=(.+)/)
const expiry = match ? new Date(match[1]) : null
const daysLeft = expiry ? Math.floor((expiry - Date.now()) / 86400000) : 0
if (daysLeft < 30) {
console.warn(
`[TLS] WARNING: Certificate expires in ${daysLeft} days (${expiry?.toDateString()}).`
)
console.warn(
'[TLS] Regenerate with: mkcert -cert-file cert.pem -key-file key.pem localhost 127.0.0.1'
)
}
Add this to your package.json predev hook so it runs automatically every time someone starts the development server:
{
"scripts": {
"predev": "node scripts/check-cert.js",
"dev": "vite"
}
}
Using 825-Day Certificates with OpenSSL
When generating self-signed certificates manually, 825 days is the practical maximum. Apple devices and Chrome enforce a cap of 825 days on the maximum leaf certificate validity they will accept. Certificates with a longer notAfter date are treated as invalid regardless of the issuer CA:
openssl req -x509 -nodes -days 825 -newkey rsa:2048 \
-keyout local-dev.key -out local-dev.crt \
-config san.cnf -extensions v3_req
Using 825 days rather than 365 reduces maintenance burden without hitting the platform cap.
Storing Certificates Outside the Repository
A practical team workflow is to store certificates in a well-known location outside the project directory and reference them via environment variables. This avoids both accidental commits and the need for each developer to regenerate certificates per project:
# ~/.config/dev-certs/ (macOS / Linux) or %APPDATA%\dev-certs\ (Windows)
mkdir -p ~/.config/dev-certs
mkcert -cert-file ~/.config/dev-certs/cert.pem -key-file ~/.config/dev-certs/key.pem \
localhost 127.0.0.1 myapp.local "*.myapp.local"
Then in each project’s .env.local:
SSL_CRT_FILE=~/.config/dev-certs/cert.pem
SSL_KEY_FILE=~/.config/dev-certs/key.pem
Developers generate the certificate once and reuse it across all projects on their machine. Regeneration is needed only when adding new hostnames or when the certificate approaches expiry.
HTTPS and Browser-Exclusive APIs
One of the most concrete and often-overlooked benefits of enabling HTTPS in local development is access to browser APIs that are gated behind a secure context. A secure context in the browser means either localhost (treated as implicitly secure) or any HTTPS origin. However, there are specific APIs and features where even localhost is not enough—the connection must genuinely be HTTPS.
Service Workers
Service workers are the backbone of Progressive Web Apps (PWAs). They intercept network requests, enable offline functionality, and manage push notifications. The Service Worker API is strictly limited to secure contexts. While localhost is allowed for development, as soon as you need to test a service worker against a custom hostname—myapp.local, for example—you must serve the application over HTTPS.
If your service worker fails to register and the console shows SecurityError: Failed to register a ServiceWorker: The URL protocol of the current origin ('http://myapp.local') is not supported., the fix is to enable HTTPS for that origin.
Geolocation, Camera, and Microphone
The browser’s navigator.geolocation, getUserMedia() (camera and microphone access), and navigator.mediaDevices APIs all require a secure context in modern browsers. Chrome removed permission prompts for these APIs on non-secure origins entirely. If you are developing a location-aware feature or a video-chat application, local HTTPS is not optional—it is mandatory for the feature to function at all.
Web Crypto API
The window.crypto.subtle interface, which provides access to cryptographic primitives like AES encryption, ECDSA signing, and HKDF key derivation, is only available in secure contexts. Applications that use client-side cryptography for end-to-end encryption or key wrapping will encounter TypeError: Cannot read properties of undefined (reading 'subtle') on plain HTTP.
Clipboard API
The modern asynchronous Clipboard API (navigator.clipboard.readText() and navigator.clipboard.writeText()) requires a secure context and a user gesture. Copy-to-clipboard features fail silently or fall back to the deprecated document.execCommand('copy') on insecure origins. Testing the true Clipboard API behaviour requires HTTPS.
Web Authentication API (WebAuthn)
The WebAuthn API, which underpins passkeys and hardware security key authentication (FIDO2/U2F), requires both a secure context and a proper relying party ID that matches the current origin’s hostname. Testing WebAuthn flows—including passkey creation and assertion—is impossible without HTTPS served from a valid hostname.
HTTP/2 and HTTP/3
Modern browsers only negotiate HTTP/2 and HTTP/3 over TLS connections (h2 and h3 ALPN extensions). Applications that benefit from HTTP/2 multiplexing—reducing the number of parallel TCP connections needed to load many small assets—cannot be tested accurately on a plain HTTP development server. Enabling local HTTPS allows you to verify performance characteristics that would otherwise only appear in production.
Securing Cookies and Authentication Flows Locally
Setting up HTTPS locally is particularly important when developing authentication systems. Cookie flags and OAuth callback behaviour differ fundamentally between HTTP and HTTPS development environments.
The Secure Cookie Flag
A cookie marked with the Secure flag is only transmitted by the browser on HTTPS connections. When developing on http://localhost, session cookies and CSRF tokens set with Secure are silently dropped—the browser records no error and the developer cannot tell why their authentication flow is broken. Enabling HTTPS allows you to test the exact same cookie policy your production server enforces.
In Express, set the Secure flag only when serving over HTTPS:
res.cookie('sessionId', token, {
httpOnly: true,
secure: process.env.NODE_ENV !== 'development' || req.secure,
sameSite: 'strict',
maxAge: 3600000
})
Or, set secure: true unconditionally once you have HTTPS running locally—this is the most production-faithful approach and the one that will surface the broadest range of real-world issues.
SameSite Attributes and Cross-Origin Requests
The SameSite=Lax attribute (the default in modern browsers) allows cookies to be sent on top-level navigations but not on cross-origin sub-resource requests. SameSite=Strict blocks the cookie on all cross-origin requests, including navigations. SameSite=None requires Secure to be set as well, or the cookie is rejected entirely.
If your application involves an API on a different subdomain from the frontend—api.myapp.local vs app.myapp.local—testing the correct SameSite behaviours requires distinct hostnames served over HTTPS. A single localhost with different ports is not sufficient because the browser treats all localhost ports as the same origin.
OAuth 2.0 Redirect URIs
When integrating with third-party OAuth 2.0 providers (Google, GitHub, Microsoft, Auth0), the provider compares the redirect_uri parameter in the authorization request against a pre-registered allow-list. Most providers accept http://localhost as a special case for development. However, some providers require HTTPS even in development, and many developers find it cleaner to register a proper local HTTPS URI from the start so the development configuration remains identical to production.
With local HTTPS:
- Register
https://myapp.local:3443/auth/callbackin your OAuth application settings. - Generate an mkcert certificate for
myapp.local. - Point your browser to
https://myapp.local:3443.
The OAuth flow completes without requiring any development-only workarounds in your application code.
JWT Validation and HTTPS-Only Token Endpoints
Some identity providers issue tokens only to clients connecting over HTTPS and reject token exchange requests from HTTP origins. If you encounter 401 Unauthorized errors when trying to exchange an authorization code for an access token in your local environment, ensure that your redirect URI is HTTPS, that your local server is actually serving over TLS, and that the Host header matches a registered redirect URI exactly.
Team Onboarding and Shared HTTPS Workflows
Configuring HTTPS locally is straightforward for a single developer, but ensuring a consistent setup across an entire team—spanning different operating systems and seniority levels—requires deliberate documentation and automation.
Providing a Setup Script
A small cross-platform setup script eliminates the “works on my machine” problem for TLS configuration. Here is a minimal example using Node.js that can be run on any platform:
// scripts/setup-https.js
const { execSync, spawnSync } = require('child_process')
const fs = require('fs')
const certFile = 'cert.pem'
const keyFile = 'key.pem'
// Check if mkcert is installed
const which = spawnSync('mkcert', ['-version'], { encoding: 'utf-8' })
if (which.status !== 0) {
console.error('mkcert is not installed. See https://github.com/FiloSottile/mkcert#installation')
process.exit(1)
}
// Install the local CA if not already done
execSync('mkcert -install', { stdio: 'inherit' })
// Generate project certificates
execSync(
`mkcert -cert-file ${certFile} -key-file ${keyFile} localhost 127.0.0.1 myapp.local "*.myapp.local"`,
{
stdio: 'inherit'
}
)
console.log(`\nCertificates written to ${certFile} and ${keyFile}`)
console.log('Add both files to .gitignore if you have not already done so.')
Add it to package.json:
{
"scripts": {
"setup": "node scripts/setup-https.js"
}
}
New team members run npm run setup after cloning the repository and their local HTTPS environment is ready.
Documenting the Setup in the README
The project README should include a dedicated section explaining:
- That the project requires HTTPS for local development and why.
- Which tool generates the certificates (mkcert, OpenSSL, etc.) and the installation command for each supported OS.
- The exact command to generate the project-specific certificate.
- How the certificate paths are configured (environment variables, config files).
- How to run the development server after setup.
- A note that certificate files must not be committed to version control.
Clear documentation prevents support requests and ensures that the purpose of the HTTPS requirement is understood—not just mechanically followed.
CI/CD Considerations
Automated test pipelines that make HTTPS requests to a locally spawned server face the same trust challenge. In CI, you generally have two options:
Option A — Use the mkcert CA in the pipeline:
The CI runner generates certificates with mkcert at pipeline start, exports the root CA path, and sets NODE_EXTRA_CA_CERTS for Node.js test runners. This is the highest-fidelity approach.
Option B — Use the --cacert flag in curl-based health checks:
If your CI pipeline uses curl smoke tests rather than Node.js HTTPS requests, pass --cacert pointing at the mkcert root CA PEM file. Avoid --insecure in CI—it hides real certificate issues.
Option C — Accept self-signed certificates only in test scope:
For unit and integration tests where you control both client and server, set NODE_TLS_REJECT_UNAUTHORIZED=0 as a last resort, but restrict it to the test environment exclusively and document why it is set. Never allow this variable to propagate to a staging or production environment.
Real-World Use Cases for HTTPS in Local Development
- Testing Secure APIs
Simulating production-like HTTPS endpoints for API testing is one of the most immediate benefits of local HTTPS. Many third-party APIs—payment gateways, identity providers, webhook delivery services—send callbacks exclusively over HTTPS. Without local HTTPS, you need a tunneling service like ngrok to receive those callbacks, which adds an external dependency to your workflow. With local HTTPS and a custom hostname, you can register https://api.myapp.local/webhooks/stripe as a callback URL in your test account and receive payloads directly without routing traffic through a third-party server.
- Building Progressive Web Apps (PWAs)
Many PWA features, like service workers, background sync, push notifications, and the Web App Manifest installation prompt, require a secure context. Developing a PWA over plain HTTP results in features being silently unavailable, making it impossible to test the full application lifecycle locally. HTTPS unlocks the complete set of PWA capabilities and gives you confidence that what works locally will work when deployed.
- Developing Secure Authentication Systems
OAuth 2.0 flows, JWT validation, session cookie lifecycle, CSRF token handling, and WebAuthn passkey flows all behave differently under HTTP versus HTTPS. Testing authentication over plain HTTP leaves entire categories of production bugs undetected—particularly those involving the Secure cookie flag, SameSite policy enforcement, and HTTPS-only token endpoints. A local HTTPS setup ensures your authentication implementation is tested under conditions that match production as closely as possible.
- Cross-Device Testing
When testing your application on a mobile device connected to the same local network, the device’s browser will also validate TLS certificates. Exporting the mkcert root CA to your mobile device and installing it as a trusted CA (described in the mkcert README for both iOS and Android) allows you to use https://192.168.x.x:3443 on the device with full certificate trust, enabling realistic mobile testing without deploying to a real server.
Future Trends in HTTPS for Development
- Automatic Certificate Management
- Tools like
mkcertand Docker Compose will streamline HTTPS setup for local environments.
- Enhanced Local SSL Tools
- Expect improvements in browser and OS support for local HTTPS testing.
- Integration with Zero-Trust Architectures
- HTTPS will play a crucial role in securing microservices and distributed systems.
HTTPS Performance Considerations in Local Development
Developers sometimes worry that adding TLS to a local server will slow down their iteration cycle. In practice, the performance overhead of TLS in a loopback connection (traffic that never leaves the machine) is negligible for most workloads. The TLS handshake adds a few milliseconds on the first connection, and modern TLS 1.3 reduces round trips from two down to one. On subsequent requests using persistent connections, the overhead is effectively zero.
That said, there are performance-related scenarios worth understanding:
TLS Session Resumption
When using HTTP/1.1, browsers open multiple TCP connections to a server to parallelize asset loading. Each new connection requires a TLS handshake. HTTP/2, however, multiplexes many request/response pairs over a single connection, amortising the handshake cost across all requests on a page. Because HTTP/2 is TLS-only in browsers, enabling local HTTPS is a prerequisite for profiling realistic HTTP/2 performance locally.
If you are building a performance-sensitive application and you want DevTools waterfall charts to reflect production behaviour, local HTTPS with a server that supports HTTP/2 (Nginx, Caddy, or Node.js http2 module) gives you the most accurate picture.
OCSP Stapling and Local Certificates
In production, browsers perform Online Certificate Status Protocol (OCSP) checks to verify that a certificate has not been revoked. For local certificates this step is skipped—self-signed and mkcert certificates do not have OCSP endpoints, and the browser is configured not to require them for locally installed CAs. This means local HTTPS connections are actually faster than production connections in one respect: there is no OCSP round-trip to a remote server.
Large Certificate Chains
If you generate a complex CA chain for testing purposes—root CA, intermediate CA, leaf certificate—include the full chain in the certificate file presented by your server. Browsers that cannot retrieve intermediate certificates will fail the handshake entirely rather than degrade gracefully. With mkcert this is handled automatically; with hand-crafted OpenSSL chains, concatenate the intermediates in order:
cat leaf.crt intermediate.crt > fullchain.crt
Keeping Hot Reload Fast
Vite, Next.js, and Create React App all support Hot Module Replacement (HMR). HMR typically uses a WebSocket connection. When HTTPS is enabled, the WebSocket must also use wss:// instead of ws://. Modern framework dev servers handle this automatically when HTTPS is configured through their official configuration options, but third-party bundler plugins sometimes require an explicit wss URL in the client configuration. Always verify HMR works after enabling HTTPS by making a small code change and confirming the browser updates without a full page reload.
Quick Setup Reference
After reading this guide in full, here is a condensed reference for the most common setups. Use this as a checklist when onboarding to a new project or machine.
New Machine Checklist
Before doing anything project-specific, set up the machine foundation once:
- Install mkcert for your OS (see the mkcert section above).
- Run
mkcert -installto register the local CA with your OS and browsers. - Add
export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"to your shell profile. - Restart your terminal.
Per-Project Checklist
For each project that needs local HTTPS:
- Generate certificates:
mkcert -cert-file cert.pem -key-file key.pem localhost 127.0.0.1 myapp.local - Add
cert.pemandkey.pemto.gitignore. - Set
SSL_CRT_FILEandSSL_KEY_FILEenvironment variables in.env.local(or pass paths directly to your server configuration). - Configure your framework dev server (Vite, CRA, Next.js, Express, Flask) to use the certificate.
- Add a
predevstep that checks certificate expiry. - Document the HTTPS setup in the project README.
Decision Tree: Which Tool to Use?
When you are deciding how to set up local HTTPS on a new project, the following questions guide the choice:
- Do you just need trusted certificates with no reverse proxy? Use mkcert.
- Do you need full control over certificate extensions (SANs, key usage, EKU)? Use OpenSSL with a config file.
- Do you need TLS termination, HTTP/2, and automatic certificate management in one binary? Use Caddy.
- Are you running multiple services behind a single entry point in Docker? Use Nginx or Caddy as a TLS-terminating reverse proxy in your Docker Compose stack.
- Are you on a team using Vite? Use
vite-plugin-mkcertfor the most seamless experience. - Are you building a Create React App project? Use environment variables pointing at mkcert certificates.
This decision tree covers the vast majority of real-world local HTTPS scenarios. When in doubt, start with mkcert—it is the easiest to set up, the hardest to misconfigure, and the most commonly used tool in the industry.
Conclusion
Setting up HTTPS for local development is a vital step toward building secure, production-ready applications. By following the steps in this guide, you can simulate secure environments, test SSL-specific features, and avoid potential issues in deployment. The combination of mkcert for trusted certificates, a framework-native HTTPS configuration, and a short team onboarding script removes virtually all friction from the process. Start implementing HTTPS in your local development workflow today and ensure your applications are built with security in mind from the very first line of code.