how to make self-signed cert work for Chrome
Learn how to use openssl with SAN for Ubuntu 24.04 so Chrome go accept your cert. We go show you how to setup nginx and trust am without using curl -k.
Wetin you dey build
Self-signed TLS certificate wey modern browsers and clients go accept — correct subjectAltName, correct key permissions, and we go connect am to nginx or Apache — plus the part wey almost every guide dey skip: how to make your clients trust am properly, instead of dem to dey click through warnings and hard-code curl -k inside scripts forever. For the end, you go get five-command private CA for when one internal service grow reach six.
First, make you decide, because self-signed certificate no be the right tool for every work as many people dey think. If the service dey reachable from public internet with real DNS name, stop reading and go get a free Let's Encrypt certificate with certbot on nginx or the Apache equivalent instead. E no cost anything, e dey renew itself, and every browser for world already trust am. If you use self-signed certificate for public site, you dey train your users to click through security warnings, and that one bad habit pass plain HTTP.
Self-signed na the right tool when public internet no dey part of the work: admin panel wey bound to a WireGuard tunnel address on your VPS, staging box for private network, service-to-service traffic between backends, home-lab appliance, or if you wan replace the placeholder certificate wey Webmin generates for itself on port 10000. Let's Encrypt no fit issue certificate for 10.8.0.1 or git.internal.lan anyway — no public CA go put private IP or fake TLD inside certificate. For those names, you be the CA.
Everything wey dey below run for fresh Ubuntu 24.04 box, wey get OpenSSL 3.0.x (use openssl version to confirm). Nothing for here need internet access; everything dey work even if machine no connect to internet.
Why the old one-liner produce certs wey Chrome reject
The command wey every pre-2017 tutorial dey give you look like dis:
# Do not run this — shown so you recognise it in old guides
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout selfsigned.key -out selfsigned.crtE go ask you series of interactive questions, e go put your hostname inside the Common Name field, and e go produce certificate wey no get subjectAltName extension. Dat certificate no go work at all. Chrome stop to read Common Name for version 58, back in April 2017 — RFC 2818 don already deprecate CN matching since year 2000 — and Firefox, Safari, curl, and Python dey behave de same way. Certificate dey identify server through SAN extension or e no dey identify anything at all, and browser go tell you so with dese exact words:
NET::ERR_CERT_COMMON_NAME_INVALID
This server could not prove that it is git.internal.lan; its security
certificate does not specify Subject Alternative Names.No amount of trust-store fiddling fit fix dat error, because certificate no name anything at all. If you dey see NET::ERR_CERT_COMMON_NAME_INVALID right now, your certificate no get SAN (or e get wrong one) and you need to mint new one. Fortunately, di fix na just one command.
Mint a certificate wey browsers go accept: one command
OpenSSL add -addext flag inside 1.1.1, wey mean say you no need to do all those config-file gymnastics wey old guides dey use to inject a SAN. For Ubuntu 24.04:
sudo openssl req -x509 -newkey rsa:4096 -sha256 -days 730 -noenc \
-keyout /etc/ssl/private/git.internal.key \
-out /etc/ssl/certs/git.internal.crt \
-subj "/CN=git.internal.lan" \
-addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"Wetin each flag dey do:
-x509dey produce self-signed certificate directly instead of signing request.-newkey rsa:4096dey generate new key inside the same step. RSA 4096 no go give old client wahala; if everything wey dey connect na modern stuff,-newkey ec -pkeyopt ec_paramgen_curve:P-256dey small and e dey fast.-noencna the OpenSSL 3.x way to write the old-nodes: no passphrase for the key. Both ways dey work. If key get passphrase, nginx go hang for every boot wey e dey wait for input, so for server key, na this one you want.-days 730— two years; more info for that number dey expiry section.-subjdey answer the interactive questions automatically. CN no dey carry weight again, but set am to the primary name anyway; some tools dey show am.-addext "subjectAltName=..."na the main flag. List every name and every IP wey clients go type:DNS:entries for hostnames (wildcards likeDNS:*.internal.landey okay),IP:entries for addresses. If anybody wan browse tohttps://10.8.0.1, theIP:10.8.0.1entry must dey there — if you use DNS-only SAN, dem go getNET::ERR_CERT_COMMON_NAME_INVALIDagain.
Confirm say SAN don land before you wire anything:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -ext subjectAltNameCorrect output:
X509v3 Subject Alternative Name:
DNS:git.internal.lan, IP Address:10.8.0.1If e print No extensions in certificate instead, the certificate no get SAN and browsers go reject am — regenerate am instead of to continue.
Lock down the key
If any user for the box fit read private key, e no be private key again. For Ubuntu, /etc/ssl/private don already dey 710 root:ssl-cert, so people no fit see am easily, but you must set the file permission well-well:
sudo chown root:root /etc/ssl/private/git.internal.key
sudo chmod 600 /etc/ssl/private/git.internal.keynginx and Apache dey read certificates as root before dem drop privileges, so root:root mode 600 dey work for dem. If the key na for service wey dey run as its own user and e dey load the key itself — like Node app, Gitea, or Python daemon — change ownership chown to that service user, but keep the mode as 600. Wetin you must never do: use mode 644, put copy inside git repository, or put copy inside /tmp.
Wire am into nginx
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name git.internal.lan;
ssl_certificate /etc/ssl/certs/git.internal.crt;
ssl_certificate_key /etc/ssl/private/git.internal.key;
location / {
proxy_pass http://127.0.0.1:3000;
}
}sudo nginx -t && sudo systemctl reload nginxnginx -t must print syntax is ok and test is successful before the reload start to work. If e print SSL_CTX_use_PrivateKey_file() failed ... key values mismatch instead, e mean say certificate and key come from two different generation runs — check the failure-modes section.
Wire it into Apache
sudo a2enmod ssl proxy proxy_httpssl alone no reach for here: the vhost below dey use ProxyPass, and if you no put mod_proxy and mod_proxy_http, the config test go fail with Invalid command 'ProxyPass', perhaps misspelled or defined by a module not included in the server configuration. Save the vhost as /etc/apache2/sites-available/git-internal.conf:
<VirtualHost *:443>
ServerName git.internal.lan
SSLEngine on
SSLCertificateFile /etc/ssl/certs/git.internal.crt
SSLCertificateKeyFile /etc/ssl/private/git.internal.key
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>sudo a2ensite git-internal
sudo apache2ctl configtest && sudo systemctl reload apache2configtest suppose answer Syntax OK. Now test am from one client machine:
curl -v https://git.internal.lan/and you go see error:
curl: (60) SSL certificate problem: self-signed certificateNo be bug. Na TLS dey work: curl never hear about your certificate and e no wan talk to server wey e no fit authenticate. The next section na the real fix — and e no be wetin half of internet dey do right now.
Make clients trust am — and di anti-patterns wey you must refuse
Wrong fixes first, we go call dem by di name dem. If you bake curl -k (or --insecure) inside script, verify=False inside Python requests, or NODE_TLS_REJECT_UNAUTHORIZED=0 inside Node — none of dem go make your certificate trusted. Dem dey switch certificate verification off, wey means di client go dey talk to any server wey show any certificate, even if attacker put one for di path. You go still dey use TLS overhead but you don lose di authentication wey na di main point. Worse part be say dese flags dey spread: person go paste am inside one cron job, den inside deploy script, den inside production code, until nobody remember which connections suppose be temporary. If verify=False still dey after di debugging session wey start am, di design don spoil.
Di correct fix na to teach each client OS say dis certificate na trusted root. For Ubuntu and Debian clients:
sudo cp git.internal.crt /usr/local/share/ca-certificates/git.internal.crt
sudo update-ca-certificatesDi line wey important for di output (one Running hooks in /etc/ca-certificates/update.d... block dey follow am):
Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.Two wahala dey hide inside dem lines. Di file must end with .crt — if you use .pem extension, di system go ignore am silently and you go get 0 added without any error message. And di content must be PEM — di file must start with -----BEGIN CERTIFICATE-----; if you get DER binary, use openssl x509 -inform der -in file.der -out file.crt to convert am first. To add di self-signed certificate itself as a root dey work because self-signed certificate na its own root.
After dat, curl, wget, git, apt, and any other thing wey dey use OpenSSL against di system bundle go trust di server without any flags at all. Some clients get dem own trust stores and dem need special treatment:
- Chrome/Chromium on Linux dey read NSS database, no be di system store:
sudo apt install libnss3-tools, dencertutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crtfor each user. - Firefox get im own store: Settings → Privacy & Security → Certificates → Import, or change
security.enterprise_roots.enabledtotrueforabout:configso e go read di system store. - Python requests carry im own CA bundle (certifi) and e dey ignore di system store: pass
verify="/usr/local/share/ca-certificates/git.internal.crt"or exportREQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt. - Node.js: export
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/git.internal.crt.
For Windows clients, double-click di .crt and install am for Trusted Root Certification Authorities; for macOS, add am to di System keychain for Keychain Access and mark am as Always Trust.
One root for many services: a small private CA
If you dey use per-certificate trust, work go hard to scale: six services times four client machines na twenty-four trust installs, and every new service go add more load. The solution na private CA — clients go trust one root, and you go use am sign certificate for every service.
The easy way na mkcert, wey dey inside Ubuntu 24.04 repos. E dey handle NSS stores (Chrome, Firefox) wey update-ca-certificates no fit reach:
sudo apt install -y mkcert libnss3-tools
mkcert -install
mkcert git.internal.lan "*.internal.lan" 10.8.0.1mkcert -install go create root and register am for every trust store for that machine; the third command go produce git.internal.lan+2.pem and git.internal.lan+2-key.pem, wey you fit use for the nginx or Apache snippets wey dey above. E design assume say na development machine — the root key go dey for any box wey run -install — so e perfect for dev laptop but e no good for server fleet.
For servers, plain OpenSSL fit do the whole CA with five commands:
openssl genrsa -out lab-ca.key 4096
openssl req -x509 -new -key lab-ca.key -sha256 -days 3650 \
-out lab-ca.crt -subj "/CN=Lab Internal CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
openssl genrsa -out git.key 2048
openssl req -new -key git.key -out git.csr -subj "/CN=git.internal.lan" \
-addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"
openssl x509 -req -in git.csr -CA lab-ca.crt -CAkey lab-ca.key \
-CAcreateserial -days 730 -sha256 -copy_extensions copy -out git.crtThe trap dey for the last command: openssl x509 -req go drop all extensions from the CSR by default, including the SAN wey you carefully add. -copy_extensions copy (na OpenSSL 3.x option, so e go work for 24.04) go carry them go; if you no use am, the signed certificate no go get SAN, and Chrome go show you NET::ERR_CERT_COMMON_NAME_INVALID again. Verify am with the same openssl x509 -noout -ext subjectAltName check wey you use before.
Distribute lab-ca.crt to clients via the trust-store steps wey dey above — you only need do am once per machine, forever. Guard lab-ca.key like gold: use mode 600, and e better make you keep am for box wey no be any of the servers wey e dey sign for, because whoever get am fit mint certificate for any name wey your clients go believe.
Expiry and rotation
Public CA certificate lifetime dey shrink — CA/Browser Forum cap new publicly trusted certificates at 200 days for March 2026 (down from 398), e go drop to 100 days for 2027, and 47 days by March 2029 — but those rules na for publicly trusted CAs only. Your private CA no follow those rules, and browsers no dey enforce dem for manually installed roots. One real-world rule dey apply: Apple platforms dey reject any TLS server certificate wey valid pass 825 days, no matter who issue am. So, if you want iPhones or Macs to connect, make sure leaf certificates no pass two years. -days 730 dey clear that bar everywhere; ten-year root with two-year leaves na better setup for internal use.
Long-lived certificates dey fail for one way: dem dey fail silently, all at once, on one date wey nobody remember to pick. Check wetin you get:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -enddatePut the renewal for calendar, or make you set cron to nag you 30 days before — openssl x509 -checkend 2592000 -in cert.crt dey exit with non-zero once expiry reach that many seconds. If you already dey use Uptime Kuma for status monitoring, its HTTPS monitors go flag certificate expiry wey dey come soon for free.
Rotation with private CA dey easy: re-run the CSR-and-sign commands, swap the files, and reload the web server. The root never change, so no client go notice anything.
Failure modes, with the strings you will see
NET::ERR_CERT_AUTHORITY_INVALID — na the expected state before you install trust, no be defect for certificate. If e still dey after you install the root: for Linux, Chrome dey read NSS instead of system store (see the certutil step); or the file wey you copy no end with .crt and update-ca-certificates talk 0 added; or the server dey show different certificate than the one you trust — use openssl s_client -connect git.internal.lan:443 </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256 compare fingerprints.
NET::ERR_CERT_COMMON_NAME_INVALID — the certificate no get SAN, or the SAN no cover the name wey dey address bar. Common case: the SAN list DNS:git.internal.lan but user browse to https://10.8.0.1. You no fit fix this one with trust-store changes; reissue the cert with the missing entry.
curl: (60) SSL certificate problem: self-signed certificate — curl no trust the certificate. The self-signed certificate in certificate chain variant mean the same thing for cert wey your private CA sign. One-off fix: curl --cacert lab-ca.crt https://...; permanent fix: the trust store. No be -k.
unable to load certificate ... Expecting: TRUSTED CERTIFICATE (or Expecting: CERTIFICATE REQUEST, or no start line) — PEM confusion. You give OpenSSL wrong kind of file: you give am key or CSR instead of certificate, or you give am DER binary instead of PEM. head -1 filename go tell you wetin you get — certificate must start with -----BEGIN CERTIFICATE-----. For DER, use openssl x509 -inform der -in file.der -out file.crt to convert am.
nginx: [emerg] SSL_CTX_use_PrivateKey_file(...) failed (SSL: error ... key values mismatch) — the certificate and key no match, usually because generation command run twice and the files mix up. Confirm am with openssl x509 -in git.internal.crt -noout -pubkey | sha256sum versus openssl pkey -in git.internal.key -pubout | sha256sum; if hashes match, the pair match. If dem differ, regenerate both together.
FAQ
Why Chrome still dey show "Not secure" even after I create self-signed certificate?
If error na NET::ERR_CERT_AUTHORITY_INVALID, certificate dey okay — Chrome just no get reason to trust am yet. Install am (or your private CA root) inside client trust store. Remember say for Linux, Chrome dey use NSS database via certutil, e no dey use system store. If error na NET::ERR_CERT_COMMON_NAME_INVALID, certificate no get Subject Alternative Name wey match the URL, so you must reissue am with -addext "subjectAltName=...".
How I fit make curl trust self-signed certificate without -k?
Copy the certificate (PEM format, .crt extension) inside /usr/local/share/ca-certificates/ and run sudo update-ca-certificates — the output must show 1 added. After that, curl go verify am like any public certificate. If you want do one-off request without touch system, curl --cacert /path/to/cert.crt dey verify against that file alone; -k dey disable verification altogether and e no good for any person script.
How long self-signed certificate fit valid?
Technically, e fit valid as long as you want — CA/Browser Forum rules (200 days now, 47 by 2029) na for public CAs, no be for private trust. For real life, make you cap server certificates at 825 days, because Apple devices dey reject anything wey pass that one regardless of who issue am. Ten-year private root with two-year (-days 730) leaf certificates na better default; just mark your calendar for renewal, because expired internal cert dey kill everything silently for date wey nobody remember.
I suppose use self-signed certificate or Let's Encrypt?
If the service get public DNS name and people fit reach am from internet, always use Let's Encrypt — e free, e dey automate, and every client don already trust am. Self-signed (or private CA) na for wetin Let's Encrypt no fit issue: private IPs, internal-only hostnames like .lan, air-gapped networks, and services wey dem hide behind VPN. The choice depend on reachability and naming, no be security strength — the cryptography na the same.
Why my certificate dey reject even after I add am to /usr/local/share/ca-certificates?
Check three things. The file must end with .crt — if na .pem extension, system go skip am silently and update-ca-certificates go report 0 added. The contents must be PEM text wey start with -----BEGIN CERTIFICATE-----, no be DER binary. And the application must actually use the system store — Chrome on Linux, Firefox, Python requests, Node.js, and Java each get their own private trust store and dem need the certificate added separately.