Self-signed certificates for local development: SANs, trust stores and the errors they cause

You start a development server, open https://localhost:3000, and Chrome fills the tab with “Your connection is not private” and NET::ERR_CERT_AUTHORITY_INVALID. That is the browser working correctly: nobody has vouched for the certificate, and it says so. This guide covers when a development machine needs HTTPS at all, one self-signed certificate versus a small private CA, the fields a certificate must carry before Chrome, Firefox and Safari accept it, where each system keeps its trust store, what dhparam.pem is for, and the habits that keep a development certificate out of production.

When plain http://localhost is already secure enough

Browsers treat http://localhost, http://127.0.0.1 and http://[::1]as “potentially trustworthy” origins under the Secure Contexts specification — Firefox since version 84, Chrome for longer — so service workers, crypto.subtle, getUserMedia, WebAuthn and the asynchronous clipboard all work there over plain HTTP. Chrome also resolves any *.localhost name to loopback and counts it as secure. You need real TLS locally when:

  • The hostname is anything else. app.test in /etc/hosts, a LAN address such as https://192.168.1.20:3000 so a phone can reach your machine, or a Docker service name — none is a secure context, so the camera prompt never appears and the service worker refuses to register.
  • You want HTTP/2 or HTTP/3. Browsers speak h2 only over TLS; cleartext h2c never shipped in any of them, and HTTP/3 is TLS 1.3 by definition.
  • Cookies must be Secure. A SameSite=None cookie is rejected unless it is also Secure, and while Chrome and Firefox accept Secure cookies from http://localhost, they drop them from http://192.168.1.20.
  • The name is on the HSTS preload list. The entire .dev and .app top-level domains are preloaded, so myproject.dev in /etc/hosts is forced to HTTPS and Chrome offers no “Proceed” link at all. Use .test (reserved by RFC 2606), .localhost or .internal for local names.

One self-signed certificate, or a private CA?

A certificate is self-signed when its issuer equals its subject and the signature was made with its own private key. There is no chain, so a client either trusts that exact certificate or trusts nothing. Two patterns follow:

  • A self-signed leaf. One certificate, one key, marked CA:FALSE, served by your dev server and trusted directly by the client. This is what the self-signed certificate generator on this site produces. Right for one hostname on one machine.
  • A private CA. One self-signed certificate marked CA:TRUE whose key signs any number of ordinary leaves. You trust the CA once per device; every leaf it issues — localhost, app.test, the LAN address, a colleague’s container — is then accepted without further ceremony. This is what mkcert automates.

One hostname on one laptop: the leaf, because there is nothing to re-trust. Several hostnames, a phone on the LAN, or teammates: the CA, because re-trusting a new leaf on every device each time a name changes is the step people skip. Anything reachable from the internet: neither — generate a CSR and get a real certificate; the CSR guide walks through it.

# create a local CA and install it in the system store (and Firefox, and Java if JAVA_HOME is set)
mkcert -install
# issue one leaf covering four names — writes ./localhost+3.pem and ./localhost+3-key.pem
mkcert localhost 127.0.0.1 ::1 app.test
# where the CA lives: rootCA.pem (share freely) and rootCA-key.pem (never share)
mkcert -CAROOT

The cost is concentrated risk: a trusted CA can sign a certificate for any hostname, so rootCA-key.pem is the most sensitive file on the machine. If a shared team CA is unavoidable, issue it with a name constraint such as nameConstraints = critical, permitted;DNS:.test, which Chrome, Firefox and macOS enforce, so a leaked key can only forge names under .test.

What browsers check, and what the generator writes

The classic one-liner — openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365 -subj "/CN=localhost"— produces a certificate no current browser accepts even after you trust it, because it has a Common Name and no Subject Alternative Name extension. Chrome stopped matching hostnames against the CN in Chrome 58 (April 2017); Apple’s rules for iOS 13 and macOS 10.15 state that “DNS names in the CommonName of a certificate are no longer trusted”. With the stock openssl.cnf, -x509 also applies the v3_ca section, so the leaf is marked CA:TRUE, which Firefox refuses. -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" fixes the first problem; the table covers the rest.

FieldWhat clients requireWhat the generator writes
Subject Alternative NameEvery name typed in the URL bar as a DNS entry, every address as an IP entry. The CN is ignored.Every entry in the SANs box plus the Common Name, de-duplicated. A dotted-quad such as 127.0.0.1 becomes an IP entry; anything else a DNS entry.
Extended Key UsageserverAuth — mandatory on Apple platforms for certificates issued after 1 July 2019.serverAuth and clientAuth.
Key UsagedigitalSignature — Chrome now enforces it and fails with ERR_SSL_KEY_USAGE_INCOMPATIBLE when it is missing.digitalSignature + keyEncipherment, critical.
Basic ConstraintsCA:FALSE on a served leaf.CA:FALSE, critical, plus a Subject Key Identifier.
Key and signatureRSA of at least 2048 bits or an ECDSA curve, signed with SHA-2.RSA 2048 / SHA-256, ECDSA P-256 / SHA-256 (default) or ECDSA P-384 / SHA-384.
ValidityAt most 825 days on Apple platforms for any TLS server certificate; the 398-day cap applies only to publicly trusted roots.1–3650 days, default 365; notBefore is set one hour in the past to absorb clock skew; 64-bit random serial.

Out come a PEM localhost.crt and a PKCS#8 localhost.key (-----BEGIN PRIVATE KEY-----), both named after the CN; a wildcard CN such as *.app.test is saved as wildcard.app.test.crt. Set a passphrase and the key becomes -----BEGIN ENCRYPTED PRIVATE KEY-----, AES-256 inside PKCS#8, which nginx decrypts with ssl_password_file and Node with the passphraseoption. The key pair comes from your browser’s WebCrypto engine and never leaves the tab. Two limits: the tool makes a leaf, not a CA, and only IPv4 addresses become IP entries — type ::1 and it is stored as a DNS name nothing will match, so for https://[::1] use mkcert.

Verify before you install anything. Drop the .crt on the certificate checker: it labels the card Self-signed, prints Issued by: localhost (itself) and Covers: localhost, 127.0.0.1, shows key type, days remaining and SHA-256 fingerprint, and verifies the self-signature. Type your hostname into the “Does it cover a specific hostname?” box: a name missing from the SAN list is reported as NOT covered, and wildcards match one label only, so *.app.test covers api.app.test but not app.test. Then point the server at the pair:

# nginx
ssl_certificate     /etc/nginx/certs/localhost.crt;
ssl_certificate_key /etc/nginx/certs/localhost.key;

# Node
https.createServer({ key: fs.readFileSync('localhost.key'), cert: fs.readFileSync('localhost.crt') }, app).listen(3000);

Trusting it: where each system keeps its store

Trust is decided by the client, and clients read different stores: Chrome, Edge and Safari use the operating system store; Firefox ships its own NSS database; Node ignores the OS and trusts only its compiled-in bundle plus whatever NODE_EXTRA_CA_CERTS names; Java uses cacerts inside the JDK. One certificate may need adding in four places on one machine, and the error you get names the one you missed.

macOS and iOS

Keychain Access → drag the .crt into the System keychain (or login for one user) → double-click → expand Trust→ set “When using this certificate” to Always Trust. On an iPhone, AirDrop the .crt, install the downloaded profile, then enable it under Settings → General → About → Certificate Trust Settings — without that step the profile is installed but not trusted.

Windows and Linux

certmgr.msc → Trusted Root Certification Authorities → Import reads the PEM .crt directly, and Chrome and Edge follow at once. Debian and Ubuntu collect anchors from /usr/local/share/ca-certificates/ (the file must end in .crt) into /etc/ssl/certs/ca-certificates.crt; Fedora and RHEL use /etc/pki/ca-trust/source/anchors/. Chrome on Linux reads the NSS database in ~/.pki/nssdb instead, so add it there too.

Firefox

Settings → Privacy & Security → View Certificates → Authorities→ Import, ticking “Trust this CA to identify websites”, is the route for a CA; for a single leaf, “Accept the Risk and Continue” on the error page stores a permanent exception for that host and port. Or set security.enterprise_roots.enabled to true in about:config to use the Windows or macOS store. Restart Firefox afterwards.

Command line, runtimes and containers

# macOS: System keychain, trusted as a root
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain localhost.crt
# Windows (administrator prompt)
certutil -addstore -f ROOT localhost.crt
# Debian / Ubuntu, then Fedora / RHEL
sudo cp localhost.crt /usr/local/share/ca-certificates/localhost.crt && sudo update-ca-certificates
sudo cp localhost.crt /etc/pki/ca-trust/source/anchors/ && sudo update-ca-trust
# Chrome / Chromium on Linux (NSS, from libnss3-tools)
certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n localhost-dev -i localhost.crt
# Java 9+ (the default store password really is "changeit")
keytool -importcert -trustcacerts -cacerts -storepass changeit -alias localhost-dev -file localhost.crt
# Node — read once at process start, so restart after setting it
export NODE_EXTRA_CA_CERTS=/path/to/localhost.crt
# Python requests / anything linked against OpenSSL / curl per call / git
export REQUESTS_CA_BUNDLE=/path/to/localhost.crt
export SSL_CERT_FILE=/path/to/localhost.crt
curl --cacert localhost.crt https://localhost:3000/
git config --global http.sslCAInfo /path/to/localhost.crt

Containers see nothing of the host’s store: mount the certificate and run update-ca-certificates in a dev-only stage, or set SSL_CERT_FILEon the container. On Android, install it via Settings → Security → Encryption & credentials → Install a certificate → CA certificate; Chrome honours that user store, but since Android 7 apps ignore user-added CAs unless their network_security_config.xml opts in.

dhparam.pem: what it is and whether you need one

Config templates often include ssl_dhparam /etc/nginx/dhparam.pem;, and creating the file with openssl dhparam -out dhparam.pem 2048 takes minutes because OpenSSL is searching at random for a 2048-bit safe prime. The file holds a prime p and generator g in PKCS#3 form, used only by the finite-field Diffie-Hellman suites of TLS 1.2 (DHE-RSA-…). Elliptic-curve suites (ECDHE-…, which every browser prefers, usually over X25519) never read it, and TLS 1.3 negotiates groups by name. Since nginx 1.11.0 the directive has no default, so omitting it simply disables DHE suites — for a dev server, the right outcome.

If a template insists on the file, do not generate one. RFC 7919 standardised vetted groups — ffdhe2048, ffdhe3072, ffdhe4096 — and Mozilla’s configuration generator ships ffdhe2048. The dhparam generator serves exactly those three as the same PEM bytes OpenSSL emits for openssl genpkey -genparam -algorithm DH -pkeyopt group:ffdhe2048: pick the size, download dhparam.pem or copy the text, and reference it with ssl_dhparam in nginx or SSLOpenSSLConfCmd DHParameters /path/dhparam.pem in Apache. Nothing in the file is secret — the group travels in the clear in every DHE handshake — and Logjam (2015) broke 512-bit export DH, not shared 2048-bit groups; the real risk of a hand-generated file is a subtly non-safe prime.

The errors, decoded

Each client reports the same three failures — not trusted, wrong name, wrong dates — in its own words. Match the message to the row; the fix names the store or the field to change.

MessageSeen inWhat it meansFix
NET::ERR_CERT_AUTHORITY_INVALIDChrome, EdgeNeither the certificate nor a CA above it is in the store this browser reads.Import into the OS store (NSS on Linux); compare the SHA-256 the browser shows with the checker’s fingerprint of your file.
SEC_ERROR_UNKNOWN_ISSUER / MOZILLA_PKIX_ERROR_SELF_SIGNED_CERTFirefoxFirefox’s own store lacks the CA (first) or the leaf (second).Import under Authorities, add an exception, or enable security.enterprise_roots.enabled; restart.
NET::ERR_CERT_COMMON_NAME_INVALID / SSL_ERROR_BAD_CERT_DOMAINChrome / FirefoxThe host in the URL is not among the SANs — often because there are no SANs, only a CN.Regenerate with the exact hostname or IP in the SAN list; confirm with the checker’s hostname box.
NET::ERR_CERT_DATE_INVALID / SEC_ERROR_EXPIRED_CERTIFICATE / certificate has expiredChrome / Firefox / curlExpired, or notBefore is in the future because a VM clock is wrong.Check date on the server; regenerate. The checker prints days remaining and can write an .ics reminder.
MOZILLA_PKIX_ERROR_CA_CERT_USED_AS_END_ENTITYFirefoxThe served certificate carries CA:TRUE — the openssl req -x509 default.Regenerate a CA:FALSE leaf, or issue a leaf from the CA instead of serving the CA itself.
DEPTH_ZERO_SELF_SIGNED_CERT / ERR_TLS_CERT_ALTNAME_INVALIDNodeNode never reads the OS store (first); the hostname is not in the SANs (second).NODE_EXTRA_CA_CERTS and restart; never NODE_TLS_REJECT_UNAUTHORIZED=0.
PKIX path building failed … unable to find valid certification path to requested targetJavaThe JDK’s cacerts does not contain it.keytool -importcert -cacerts as above, then restart the JVM.

When the error contradicts what you installed, check what the server actually sends: the fingerprints from these two commands (and the checker’s SHA-256 line) must match, or a reverse proxy, stale container or second config block is serving an older certificate.

openssl s_client -connect localhost:3000 -servername localhost </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -ext subjectAltName -dates -fingerprint -sha256
openssl x509 -in localhost.crt -noout -fingerprint -sha256

Common mistakes, and the hygiene that keeps dev certificates out of production

  • Committing the key. Add *.key, *-key.pem, *.pfx and *.p12 to .gitignore before generating anything; the public .crt may be committed. A pushed-then-deleted key is still in the history: regenerate it, and untrust a leaked CA on every device that had it.
  • Baking the CA into an image. A COPY rootCA.pem /usr/local/share/ca-certificates/ in the Dockerfile ends up in production, where anyone holding the dev CA key can impersonate every host your service talks to. Keep it in a development stage or a bind mount and check docker history before pushing.
  • Switching verification off instead of trusting. NODE_TLS_REJECT_UNAUTHORIZED=0, rejectUnauthorized: false, curl -k, verify=False and git config http.sslVerify false disable checking for every connection, and environment variables travel into CI and production.
  • One CA key for the whole team.If everyone trusts the same CA and one laptop leaks its key, every teammate’s browser can be intercepted. Each developer runs mkcert -install themselves; a genuinely shared CA carries a name constraint.
  • Ten-year certificates.The generator allows 3650 days, but Apple stops at 825. Use 365 and let the checker’s renewal reminder, which fires 30 days before expiry, tell you when to regenerate.

Do this

  • Stay on http://localhost if it is the only address you use — it is already a secure context.
  • For one other hostname, generate a self-signed leaf with every name and IPv4 address in the SAN list, 365 days, ECDSA P-256; for several names or devices, run mkcert -install and issue leaves from the CA.
  • Drop the certificate on the checker and confirm the hostname box says “Covered” before touching any trust store.
  • Trust it in the store the failing client reads — OS store for Chrome, Edge and Safari, Firefox’s own, cacerts for Java, NODE_EXTRA_CA_CERTS for Node — then restart that client.
  • Ignore *.key and rootCA-key.pemin git, keep the CA out of production images, and regenerate when the checker’s renewal reminder fires.

Frequently asked questions

Is a self-signed certificate secure?

The encryption is identical to a certificate from a public CA — same TLS, same ciphers. What is missing is a third party vouching for the name, so browsers warn until you trust the certificate yourself. On machines you control that is fine; on a public site it teaches visitors to click through warnings, so use a real CA there.

Why does Chrome still show NET::ERR_CERT_AUTHORITY_INVALID after I imported the certificate?

Usually the certificate went into a store that browser does not read — the wrong keychain on macOS, the user store instead of the machine store on Windows, or the OS store when Firefox keeps its own — or the browser was not restarted. Compare the SHA-256 fingerprint the browser shows with the one the certificate checker prints for your .crt; if they differ, the server is presenting a different file.

How long can a self-signed certificate be valid?

The generator allows up to 3650 days, but Apple platforms reject any TLS server certificate valid for more than 825 days; the 398-day limit applies only to publicly trusted roots. One year is the practical default: it works everywhere and forces a rotation habit.

Do I need a dhparam.pem file for a local HTTPS server?

No. Browsers negotiate ECDHE or TLS 1.3 with a modern nginx, Apache or Node, and neither uses the file. It only matters if your config template enables classic finite-field DHE cipher suites — in that case use the RFC 7919 ffdhe2048 group rather than generating your own.

Can I use a self-signed certificate on a public website?

Not usefully — every visitor sees a full-page warning, and browsers will not let them past it on HSTS domains. Public hostnames need a certificate from a trusted CA; Let’s Encrypt issues them free from a CSR.

Tools used in this guide

Every one of these runs in your browser — the files you work on never leave your device.

More certificates & keys guides