Skip to content
ecdsa.com

Self-signed vs CA-signed certificates: when each one is right

Both encrypt equally well. The difference is who vouches for the key, how clients learn to trust it, and what breaks when you rotate it.

Self-signed vs CA-signed is a question about who vouches for the binding between a public key and a name. A self-signed certificate is signed with its own key, so it proves only that its holder has that key; a client trusts it only if it was told to trust that exact certificate. A CA-signed certificate is signed by a certificate authority whose root the client already trusts: a public CA for the internet, or a private CA that you run and distribute yourself.

At a glance

Self-signedPrivate CAPublic CA
Who signs the leafThe leaf's own keyYour root or intermediateA CA in the browser and OS root stores
How clients learn to trust itEach client pins or installs this exact certificateInstall one root (MDM, configuration management, container image)Already trusted by default
Rotating the leafEvery client must be updatedTransparent to clientsTransparent to clients
RevocationNone; remove it from every clientYour own CRL, OCSP or short lifetimesCRLs published by the CA
Names it can carryAnythingAnything the root is constrained toPublic domain names and public IPs only
Lifetime limitsNone from browsers; Apple platforms cap TLS certificates issued since July 2019 at 825 daysSame 825-day cap on Apple platforms200 days now, 47 days from March 2029
Certificate TransparencyNot loggedNot loggedLogged publicly, including every hostname
Cost and effortNone up front; high per rotationRunning the CA, protecting its keyFree with ACME; needs DNS or HTTP validation
Typical toolsopenssl req -x509, mkcert for local developmentstep-ca, HashiCorp Vault PKI, cloud private CAs, cert-managerLet's Encrypt and other ACME CAs

How clients decide to trust a certificate

Path validation (RFC 5280 §6) builds a chain from the server's certificate up to a trust anchor in the client's root store, checking each signature, validity period, basic constraints, key usage and name constraints on the way. A self-signed certificate has the same issuer and subject and is signed by its own key, so the only chain possible is the certificate itself. It validates only if that certificate is itself a trust anchor for the client, which is exactly what "pinning" or "installing" a self-signed certificate means.

A CA-signed leaf is signed by an intermediate CA, which in turn chains to a root CA. The client never needs to know the leaf in advance; it needs only the root. That indirection is what makes rotation, revocation and automation possible.

The script below, run with OpenSSL 3.6.5, creates a self-signed certificate, a private root limited to names under .internal, and two leaf certificates signed by that root.

Self-signed certificate, private CA and name constraints
#!/bin/sh
dir=$(mktemp -d)
cd "$dir" || exit 1
P256="-newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc"

# 1. Self-signed server certificate
openssl req -x509 $P256 -keyout self.key -out self.pem -days 30 \
  -subj "/CN=dev.internal" \
  -addext "subjectAltName=DNS:dev.internal,IP:127.0.0.1" \
  -addext "basicConstraints=critical,CA:FALSE" 2>/dev/null
openssl verify self.pem
openssl verify -CAfile self.pem self.pem

# 2. Private root CA limited to .internal names
openssl req -x509 $P256 -keyout ca.key -out ca.pem -days 3650 \
  -subj "/CN=Example Internal Root" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" \
  -addext "nameConstraints=critical,permitted;DNS:.internal" 2>/dev/null

# 3. Two server certificates signed by that CA
for name in api.internal www.example.com; do
  openssl req -x509 $P256 -CA ca.pem -CAkey ca.key -keyout "$name.key" \
    -out "$name.pem" -days 90 -subj "/CN=$name" \
    -addext "subjectAltName=DNS:$name" \
    -addext "basicConstraints=critical,CA:FALSE" \
    -addext "extendedKeyUsage=serverAuth" 2>/dev/null
done
openssl verify api.internal.pem
openssl verify -CAfile ca.pem api.internal.pem
openssl verify -CAfile ca.pem www.example.com.pem
cd / && rm -rf "$dir"
Output
CN=dev.internal
error 18 at 0 depth lookup: self-signed certificate
error self.pem: verification failed
self.pem: OK
CN=api.internal
error 20 at 0 depth lookup: unable to get local issuer certificate
error api.internal.pem: verification failed
api.internal.pem: OK
CN=www.example.com
error 47 at 0 depth lookup: permitted subtree violation
error www.example.com.pem: verification failed

The self-signed certificate verifies only when it is passed as its own trust anchor. The private CA's leaf verifies once the root is trusted, and the root's name constraints stop it from vouching for www.example.com, which limits the damage if the CA key is ever misused. Note that openssl req -x509 marks certificates as CAs by default through its configuration file; the explicit CA:FALSE keeps the self-signed server certificate from being usable as an issuer.

What clients say when they do not trust it

We pointed five clients at a local server presenting each certificate without adding anything to their trust stores (20 October 2026, macOS).

ClientSelf-signed certificateLeaf from an untrusted private CA
Node.js 26.10DEPTH_ZERO_SELF_SIGNED_CERTUNABLE_TO_VERIFY_LEAF_SIGNATURE
Python 3.14 (OpenSSL 3.6.5)certificate verify failed: self-signed certificatecertificate verify failed: unable to get local issuer certificate
Go 1.25 (macOS verifier)x509: certificate signed by unknown authorityx509: “localhost” certificate is not trusted
curl 8.7.1 (LibreSSL)(60) SSL certificate problem: self signed certificate(60) SSL certificate problem: unable to get local issuer certificate
OpenSSL 3.6.5 s_clientVerify return code: 18 (self-signed certificate)Verify return code: 20 (unable to get local issuer certificate)

Browsers show NET::ERR_CERT_AUTHORITY_INVALID (Chrome) or MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT (Firefox). The fix is never to disable verification (NODE_TLS_REJECT_UNAUTHORIZED=0, verify=False, InsecureSkipVerify, curl -k): it removes authentication for every connection the process makes. Instead, give the client the certificate or root it should trust (ca: in Node.js, cafile= in Python, a custom RootCAs pool in Go, --cacert in curl), or install the root in the operating system store. A certificate without a DNS name in the Subject Alternative Name extension fails differently: Chrome stopped matching the common name in version 58 (2017), and Apple platforms since iOS 13 and macOS 10.15.

The middle path: a private CA

A private CA keeps the main benefit of self-signing (no outside party, any internal name, no public logging) while removing its main cost (redistributing trust at every rotation). Practical rules:

  • Keep the root offline and issue from an intermediate; a compromised intermediate can be replaced without touching every client.
  • Constrain names to your internal zones, as in the demo, and keep pathlen and key usage tight.
  • Automate leaves: step-ca, Vault PKI and cert-manager can issue through ACME or their own APIs, so leaves can live for hours or days and revocation becomes unnecessary.
  • Respect client rules anyway: Apple platforms require a DNS name in the SAN, the serverAuth extended key usage and at most 825 days of validity for TLS certificates issued since 1 July 2019, also for private roots; the 398-day limit applies only to publicly trusted roots.
  • Plan root distribution: operating system stores, Java keystores, container images, browsers with their own stores (Firefox) and software that ships its own bundle (the certifi package behind Python's requests, and Node.js, which uses its built-in Mozilla bundle unless started with --use-system-ca) each need the root.

Where a public CA cannot help

Public CAs may not issue certificates containing internal names or reserved IP addresses (Baseline Requirements §4.2.2). Names such as printer.local, single-label hosts, 10.0.0.5, or anything under .internal, which ICANN permanently reserved for private use on 29 July 2024, can therefore only come from a private CA or a self-signed certificate. Internal hosts with public names (for example db.corp.example.com) can get public certificates through the DNS-01 challenge without being reachable from the internet, but every name then appears in public Certificate Transparency logs, which reveals your internal topology to anyone searching them.

When to choose which

  • Public websites, APIs and mail servers: public CA with ACME.
  • Internal services, service meshes, mTLS between workloads: private CA with short-lived leaves.
  • Local development: a local development CA such as mkcert, which installs its root in your own trust stores, rather than self-signed certificates that every tool rejects differently.
  • Devices and appliances on first boot: a self-signed certificate whose fingerprint the user or the provisioning system verifies once, followed by enrolment in a private CA.
  • Test fixtures: self-signed certificates generated by the test itself and passed explicitly to the client under test.
  • Partners and B2B integrations over mTLS: a private CA dedicated to that relationship, never your general internal root.

Migration notes

  1. Inventory self-signed certificates in use: scan internal endpoints and collect the certificates clients pin; the certificate decoder shows issuer, SANs and expiry.
  2. Stand up a private CA with an offline root and an issuing intermediate, constrained to your internal domains; generate requests with the CSR generator.
  3. Distribute the root before switching any server, then replace leaves service by service; the chain builder confirms each server sends its intermediate.
  4. Remove insecure workarounds (-k, verify=False, InsecureSkipVerify) once clients trust the root, and search the codebase so they do not come back.
  5. Move public-facing names to a public CA with ACME, and keep pinning (if any) on keys rather than certificates; the SPKI pin generator computes pins that survive renewal with the same key.

References

All comparisons