Guides Certificates

Fix “unable to get local issuer certificate”

Updated 21 min read

Short answer

The client couldn’t link the site’s certificate to a root certificate it trusts. Usually the server isn’t sending its intermediate certificate. Sometimes the client trusts a different set of roots.

What to do: check the domain with the certificate chain checker. If it finds a problem, serve the full chain from the server. If the chain is healthy, check the client’s CA bundle.

A chain that works today can break at the next renewal. Expiry Watch checks your sites daily and emails you if a certificate stops being valid or nears expiry. Free. Watch a domain

A TLS client trusts a certificate only if it can build a chain from that certificate up to a root it already trusts. The server is expected to send its own certificate (the leaf) and every intermediate certificate in between; the client supplies the root from its trust store. When a link is missing, expired or doesn’t match, you get a chain error.

Leaf certificate example.com, sent by your server first signed by Intermediate certificate sent by your server, after the leaf signed by Root certificate already in the client’s trust store
Each certificate is signed by the one below it. The server must send every certificate except the root.

Server or client?

An issuer error such as “unable to get local issuer certificate” means one client couldn’t build a chain to a root it trusts. Either the server sends an incomplete chain, or that client trusts a different set of roots than the certificate needs. Find out which before you change anything:

  1. Check the domain with the certificate chain checker. It shows every certificate the site sends, in order, and whether they lead to a trusted root.
  2. If it finds a problem, fix the server: match the error below, then fix it.
  3. If the chain is healthy, leave the server alone for now and look at the client that fails: the CA bundle it trusts, the exact hostname and port it connects to, whether it reaches a different endpoint (an internal address, a staging host, another region), and whether a VPN or a corporate proxy sits in between.

A healthy result describes what our network saw on port 443: each of the domain’s public addresses we checked, up to 4 IPv4 and 4 IPv6, judged against our checker’s trust store. An address we skipped or couldn’t finish wasn’t checked, so it proves nothing either way. The result doesn’t see addresses past those, every server behind a load balancer or every edge of a CDN, an origin server hidden behind a CDN, a name that only resolves inside your network, or the certificate a corporate proxy shows its users in place of the site’s.

Match the error to the cause

The same five problems show up under different names in every client. These are the exact messages from OpenSSL 3.6, Node.js 24 and Python 3.14 (via urllib), and the error codes from Chromium 156, captured on October 14, 2026 against the badssl.com test sites:

Missing intermediate certificate

  • OpenSSL s_client: Verify return code: 21 (unable to verify the first certificate)
  • Node.js: UNABLE_TO_VERIFY_LEAF_SIGNATURE
  • Python: unable to get local issuer certificate
  • Chromium: often loads anyway (see why the browser works)

Expired certificate

  • OpenSSL: 10 (certificate has expired)
  • Node.js: CERT_HAS_EXPIRED
  • Python: certificate has expired
  • Chromium: ERR_CERT_DATE_INVALID (some Chrome builds report ERR_CERT_AUTHORITY_INVALID for this test site, because its old chain is no longer trusted either)

Hostname not on the certificate

  • OpenSSL: 62 (hostname mismatch), but only when you add -verify_hostname
  • Node.js: ERR_TLS_CERT_ALTNAME_INVALID
  • Python: Hostname mismatch, certificate is not valid for '…'
  • Chromium: ERR_CERT_COMMON_NAME_INVALID

Self-signed certificate

  • OpenSSL: 18 (self-signed certificate)
  • Node.js: DEPTH_ZERO_SELF_SIGNED_CERT
  • Python: self-signed certificate
  • Chromium: ERR_CERT_AUTHORITY_INVALID

Chain ends in an untrusted root

  • OpenSSL: 19 (self-signed certificate in certificate chain)
  • Node.js: SELF_SIGNED_CERT_IN_CHAIN
  • Python: self-signed certificate in certificate chain
  • Chromium: ERR_CERT_AUTHORITY_INVALID

curl’s wording depends on its version and TLS library, but it contains the same phrases: SSL certificate problem: certificate has expired, unable to get local issuer certificate, no alternative certificate subject name matches target host name. Firefox reports an unknown or missing issuer as SEC_ERROR_UNKNOWN_ISSUER.

Read the chain a server sends

openssl s_client -showcerts prints every certificate the server sent, in order. For each one, s: is the subject (who the certificate is for) and i: is the issuer (who signed it). In a healthy chain, each certificate’s issuer is the next certificate’s subject:

openssl s_client -connect example.com:443 \
    -servername example.com -showcerts </dev/null 2>/dev/null \
  | grep -E '^Certificate chain| [0-9]+ s:|   i:|Verify return code'
Certificate chain
 0 s:CN=example.com
   i:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
 1 s:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
   i:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2
 2 s:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2
   i:C=US, O=SSL Corporation, CN=SSL.com TLS ECC Root CA 2022
 3 s:C=US, O=SSL Corporation, CN=SSL.com TLS ECC Root CA 2022
   i:C=GB, ST=Greater Manchester, L=Salford, O=Comodo CA Limited, CN=AAA Certificate Services
Verify return code: 0 (ok)

Now a server that forgot its intermediate. It sends only the leaf, whose issuer (YR1, a Let’s Encrypt intermediate) is nowhere in the output:

openssl s_client -connect incomplete-chain.badssl.com:443 \
    -servername incomplete-chain.badssl.com -showcerts </dev/null 2>/dev/null \
  | grep -E '^Certificate chain| [0-9]+ s:|   i:|Verify return code'
Certificate chain
 0 s:CN=*.badssl.com
   i:C=US, O=Let's Encrypt, CN=YR1
    Verify return code: 21 (unable to verify the first certificate)

Two details trip people up:

  • s_client does not check the hostname unless asked. Against wrong.host.badssl.com it reports Verify return code: 0 (ok) until you add -verify_hostname wrong.host.badssl.com. Then it returns 62 (hostname mismatch).
  • The chain section is what the server sent, not the chain OpenSSL verified. The verdict is the Verify return code line, and s_client carries on after a failed verification unless you add -verify_return_error. Most clients other than browsers add only the root, from their trust store; they don’t go looking for missing intermediates.

Check a chain file before you deploy it

To test a chain file on disk, such as Certbot’s fullchain.pem, use openssl verify. openssl x509 hands it the first certificate in the file, which should be the site’s own; openssl verify checks that certificate for your hostname and for a TLS server, and builds its chain to a root this machine trusts, taking whatever intermediates it needs from the whole file, however many it holds. The second command is the same check on cert.pem, the leaf alone, to show what a missing intermediate looks like:

openssl x509 -in fullchain.pem \
  | openssl verify -purpose sslserver -verify_hostname example.com \
      -untrusted fullchain.pem
openssl x509 -in cert.pem \
  | openssl verify -purpose sslserver -verify_hostname example.com \
      -untrusted cert.pem
stdin: OK
CN=example.com
error 20 at 0 depth lookup: unable to get local issuer certificate
error stdin: verification failed

We captured this on October 28, 2026 with OpenSSL 3.5.5 on Linux: fullchain.pem held the four certificates example.com sent that day, and cert.pem only the first. For your own file, run the first command with both of its paths and example.com replaced. It only reads the file it names and writes nothing, so no file left from an earlier check can stand in for it.

  • stdin: OK and exit status 0: the first certificate is valid now for that name and for a TLS server, and its chain reaches a root this machine trusts. stdin is the certificate openssl x509 handed over.
  • verification failed and a nonzero exit status: the lines before it say why, such as unable to get local issuer certificate (an intermediate is missing), certificate has expired, hostname mismatch, unsuitable certificate purpose or self-signed certificate in certificate chain (a root clients don’t trust); match it to its cause. A hostname mismatch can also mean the leaf isn’t first in the file.
  • Any other error, and a nonzero exit status: OpenSSL couldn’t run the check, usually because it couldn’t read a certificate from the file, so there is no verdict on the chain.

A pass describes this file on this machine, not every client. It trusts what this machine’s OpenSSL trusts, including any root or intermediate installed here, which a client’s own bundle may lack. It runs offline, so it doesn’t check revocation, and it never reads the private key, so it can’t tell whether the key matches. Nor can it see what a server sends: after you deploy, check that with s_client as above or the certificate chain checker. Keep the file in -untrusted: given as -CAfile, any root in it would count as trusted, so a chain clients reject could pass.

Why the browser works and curl doesn’t

A missing intermediate is the error most likely to pass unnoticed, because browsers often repair it:

  • Firefox “pre-downloads all trusted Web Public Key Infrastructure (PKI) intermediate CA certificates”, according to Mozilla, precisely so users don’t hit this misconfiguration.
  • Other browsers fetch the missing certificate in the background from the address embedded in the leaf (its Authority Information Access, or AIA, field), as Mozilla’s notes describe. The incomplete-chain leaf above names http://yr1.i.lencr.org/ as its issuer’s address.
  • macOS’s bundled curl uses Apple’s LibreSSL, which also consults the macOS system trust store (curl’s author has written about this in the context of --cacert). In our test it loaded incomplete-chain.badssl.com without an error.

Command-line tools on Linux, Node.js, Python, Go, Java, mobile apps, webhooks and API clients generally do none of this. So “it works in my browser” proves nothing about the chain; test with openssl s_client or one of the clients above.

Check the client’s CA bundle

When the public chain is healthy and one client still fails, compare what that client trusts with what the certificate needs. Every client verifies against its own set of roots, and they don’t always match each other or a browser. Even openssl s_client and openssl verify judge against OpenSSL’s default store on that machine, which may not be the bundle your failing client reads.

curl: which CA store is it using?

curl verifies the server’s certificate by default, against a CA store that depends on how it was built, as curl’s notes on SSL certificates explain. The first line of curl --version names its TLS library, and verbose output names the CA file and directory where the build uses them:

curl --version | head -n 1
curl -v --max-time 10 -o /dev/null https://example.com 2>&1 | grep -E 'CAfile|CApath|SSL certificate'

Lines naming CAfile and CApath show what this curl reads; a build that uses the operating system’s own store may print neither. To test against a bundle you trust, name it for one command with --cacert, or, where curl reads a CA file rather than the system’s native store, for every curl command in the shell with CURL_CA_BUNDLE:

curl -sS --max-time 10 --cacert /path/to/trusted-ca-bundle.pem -o /dev/null https://internal.example.com && echo verified
export CURL_CA_BUNDLE=/path/to/trusted-ca-bundle.pem

--cacert replaces only the CA file: if the verbose lines still show a CApath with it, curl reads that directory too. Whether it also uses the system’s native store depends on its TLS library and settings, as curl’s notes explain. So a success shows that this curl verifies the site with that bundle, not that the bundle alone is enough.

Python Requests: certifi, and what overrides it

Requests verifies certificates by default and raises SSLError when it can’t (Requests documentation). It doesn’t use the browser’s roots. For a call like requests.get(url) it takes the first of these:

  1. a verify= path given with the call: a CA bundle file, or a directory prepared with OpenSSL’s c_rehash;
  2. otherwise the REQUESTS_CA_BUNDLE environment variable, or CURL_CA_BUNDLE if that isn’t set;
  3. otherwise the bundle from the certifi package.

The certifi bundle itself varies: certifi is a separate package with releases of its own, which Requests recommends upgrading often, so one script can trust different roots in a virtualenv, the system Python, a container and a CI job. The environment variables apply only while the session’s trust_env is on (the default), and a prepared request sent with Session.send() ignores them, as the documentation warns, unless you merge the environment settings in yourself.

This prints the bundle Requests will use for a URL, then connects with verification on:

import requests
from requests.utils import DEFAULT_CA_BUNDLE_PATH

url = "https://example.com"
verify = requests.Session().merge_environment_settings(url, {}, None, None, None)["verify"]
print("CA bundle:", DEFAULT_CA_BUNDLE_PATH if verify is True else verify)
try:
    response = requests.get(url, timeout=10, allow_redirects=False)
    print("verified, HTTP", response.status_code)
except requests.exceptions.SSLError as error:
    print("not verified:", error)

Any HTTP status means the chain verified with that bundle. An SSLError naming unable to get local issuer certificate means the bundle has no root the server’s chain leads to, or the server left out an intermediate.

Node.js: bundled roots, and what adds to them

Node.js reports a missing root as UNABLE_TO_GET_ISSUER_CERT_LOCALLY: the chain the server sent ends in a certificate whose issuer isn’t among the CA certificates the process trusts. Unless it was built to default to OpenSSL’s store, Node.js trusts neither the operating system’s roots nor the browser’s, but its own copy of Mozilla’s root store, “fixed at release time” and “identical on all supported platforms” (Node.js documentation). So two Node.js versions can trust different roots, and a root installed on the machine doesn’t count unless you ask for it.

The script and commands below need Node.js 24. Run them with the runtime your application uses, not a newer one: each release brings its own roots, so a newer probe may not reproduce an older application.

This requests one URL through Node’s core node:https module, with verification on, and gives up after 10 seconds. Save it as check-tls.mjs:

// check-tls.mjs  Usage: node check-tls.mjs https://example.com
import https from 'node:https';
import tls from 'node:tls';

const url = new URL(process.argv[2]);
const extra = tls.getCACertificates('extra').length;
console.log(`Node.js ${process.version}, extra CA certificates: ${extra}`);

const options = {
  rejectUnauthorized: true,
  signal: AbortSignal.timeout(10_000),
};
https
  .get(url, options, (res) => {
    console.log(`verified: HTTP ${res.statusCode}`);
    res.destroy();
  })
  .on('error', (err) => {
    console.log(`error: ${err.code ?? err.name}`);
    process.exitCode = 1;
  });
node check-tls.mjs https://example.com
node check-tls.mjs https://valid.yr.test-certs.letsencrypt.org
Node.js v24.22.0, extra CA certificates: 0
verified: HTTP 200
Node.js v24.22.0, extra CA certificates: 0
error: UNABLE_TO_GET_ISSUER_CERT_LOCALLY

We captured this on October 26, 2026 with Node.js 24.22.0 on Linux. Let’s Encrypt’s test site sends its certificate and the YR1 intermediate but not the cross-signed root, and Node.js 24.22.0’s tls.rootCertificates has no Root YR, so there the chain falls short (Let’s Encrypt YE1 and YR1 chain errors explains why). When the chain checker finds a site healthy, the same code can still come from the endpoint your application reaches, which may send a different chain than the addresses we checked; or the process may lack a root its connection needs, such as a private CA’s or that of a proxy that inspects TLS.

  • verified: HTTP with any status, a redirect or a 404 included, and exit status 0: the certificate and the hostname verified with the CA certificates the process trusts.
  • A certificate code such as UNABLE_TO_GET_ISSUER_CERT_LOCALLY or ERR_TLS_CERT_ALTNAME_INVALID (the others), and exit status 1: Node.js refused the certificate.
  • A network code such as ENOTFOUND or ECONNREFUSED, or ABORT_ERR after 10 seconds, and exit status 1: the connection failed before any verdict on the certificate, so it says nothing about the chain.
  • The first line counts the certificates loaded from NODE_EXTRA_CA_CERTS (tls.getCACertificates('extra')).

This probes core node:https only. It may not reproduce your application’s HTTP library, which can bring its own agent, CA list or proxy, nor the endpoint the application really reaches, so run it where the application runs, with the same Node.js version, flags and environment. A separate process also doesn’t see CA changes the application makes inside its own process.

Three common things change what Node.js trusts, with verification still on:

--use-system-ca adds the certificates the operating system trusts to the bundled roots and any from NODE_EXTRA_CA_CERTS (Node.js documentation). On Windows and macOS it reads the system’s trust settings; elsewhere, the default file and directory of the OpenSSL that Node.js links to, typically /etc/ssl/cert.pem and /etc/ssl/certs. If your organization already manages its root in the system store, node --use-system-ca check-tls.mjs https://… shows whether that is the missing link.

NODE_EXTRA_CA_CERTS names a PEM file of one or more certificates to trust beside the others (Node.js documentation). Use it for an approved private CA or TLS-inspecting proxy, with the root certificate from whoever runs it (your IT or platform team), never one saved from the failing connection:

NODE_EXTRA_CA_CERTS=/path/to/corporate-root.pem \
  node check-tls.mjs https://internal.example.com

Node.js reads the variable only when the process starts: setting process.env.NODE_EXTRA_CA_CERTS inside a running program changes nothing, so set it where the process is launched, such as the service’s unit file, the container’s environment or the CI job. If the file is missing or holds no certificate Node.js can read, it carries on without it, at most with a warning, so check the count on the first line. The variable is also ignored when node runs as setuid root or with Linux file capabilities.

An explicit ca option on a TLS or HTTPS client replaces the whole default list for that connection: the bundled roots, the system’s and the extra ones (tls.createSecureContext()). So when a library passes its own CA list, NODE_EXTRA_CA_CERTS and --use-system-ca don’t reach that connection: configure the library’s CA list with the approved roots it needs. Where nothing overrides ca, NODE_EXTRA_CA_CERTS is the documented way to add to the defaults.

Fix it in the client, with verification on

  • A public site the checker finds healthy: update the client’s CA bundle (certifi for Requests, or the system’s CA certificates package), and look for a stale verify=, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE or --cacert that points at an old or partial file.
  • A private CA, or a corporate proxy that inspects TLS: get its root certificate from whoever runs it (your IT or platform team), not from the failing connection, and point the client at a bundle that contains it: verify= or REQUESTS_CA_BUNDLE for Requests, --cacert or CURL_CA_BUNDLE for curl, NODE_EXTRA_CA_CERTS at launch for Node.js. As Requests’ documentation puts it, HTTPS through a proxy typically requires trusting the proxy’s root. For Requests, a bundle you name replaces the default one rather than adding to it, so include the public roots too if the same code also calls public sites.
  • Not a fix: turning verification off (curl’s -k, Requests’ verify=False, Node.js’s NODE_TLS_REJECT_UNAUTHORIZED=0), or saving the certificate a connection presents and trusting it. That certificate is the one that just failed to verify; if a proxy or an attacker sits in between, you’d be trusting them. Requests warns that with verify=False it accepts any certificate, which leaves the application open to man-in-the-middle attacks, and Node.js’s documentation says that variable makes TLS insecure and its use is “strongly discouraged”.

Fix it

Missing intermediate: serve the full chain

Configure the server with a file that contains the leaf certificate first, followed by the intermediates. Certbot already writes this file as fullchain.pem: “the server certificate is the first one in this file, followed by any intermediates”. cert.pem on its own is the usual culprit.

Let’s Encrypt certificates issued by YE1, YE2, YR1 or YR2 also need a cross-signed root in that file; Let’s Encrypt YE1 and YR1 chain errors explains why.

If your CA sent the certificate and the intermediates as separate files, join them in that order:

cat example.com.crt intermediate-bundle.crt > example.com.fullchain.crt

nginx. Point ssl_certificate at the full chain. Per the nginx documentation, “the primary certificate comes first, then the intermediate certificates.” If the order is wrong, nginx refuses to start with a key values mismatch error (details).

server {
    listen 443 ssl;
    server_name example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}

Then sudo nginx -t && sudo systemctl reload nginx.

Apache httpd 2.4.8 or later. SSLCertificateFile accepts the leaf plus intermediates, “sorted from leaf to root”; the old SSLCertificateChainFile directive is deprecated.

<VirtualHost *:443>
    ServerName example.com
    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
</VirtualHost>

Then sudo apachectl configtest && sudo systemctl reload apache2 (the service is httpd on Red Hat family systems).

Caddy. With automatic HTTPS, Caddy obtains and renews certificates itself and you configure nothing. If you bring your own files with the tls directive, the documentation only says they are “the certificate and private key PEM files”; give it the full-chain file anyway, so clients receive the intermediates:

example.com {
    tls /etc/ssl/example.com.fullchain.crt /etc/ssl/example.com.key
}

Cloudflare. Two separate chains are involved when Cloudflare proxies your site:

  • Visitors to Cloudflare. Cloudflare serves the edge certificate. If you upload a custom certificate, its bundle method decides the chain: Compatible (the default) and Modern let Cloudflare pick intermediates; choose User-defined “if you must ensure the chain you upload is the one used”.
  • Cloudflare to your origin. In Full (strict) mode, Cloudflare requires the origin certificate to be unexpired, issued by a publicly trusted CA or Cloudflare’s Origin CA, and to match the hostname; otherwise visitors see error 526. A Cloudflare Origin CA certificate is trusted only by Cloudflare, so if you pause Cloudflare or turn off proxying for that hostname, visitors get untrusted-certificate errors.

Hostname mismatch: get a certificate that names the host

List the names a certificate covers:

echo | openssl s_client -connect wrong.host.badssl.com:443 \
    -servername wrong.host.badssl.com 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name: 
    DNS:*.badssl.com, DNS:badssl.com

On macOS’s bundled LibreSSL openssl, which lacks -ext, use openssl x509 -noout -text | grep -A1 'Subject Alternative Name' instead.

A wildcard covers exactly one label: *.badssl.com matches wrong.badssl.com but not wrong.host.badssl.com, which is why this site fails. Fix it by adding the exact name to the certificate (or a wildcard at the right level), or by making sure the server picks the right certificate for that name: on servers with several certificates, check that the server_name (nginx) or ServerName/ServerAlias (Apache) matches, and that clients send SNI.

Expired leaf or intermediate

An expired leaf needs renewing, then a reload of the server that serves it. An expired intermediate usually means an old bundle file: the CA issued a new intermediate, and your server still sends the old one. Download the current chain from your CA (or let your ACME client write a fresh fullchain.pem) and replace the bundle. Check SSL expiry: openssl, curl, browser shows how to read each date.

Self-signed or private root

A certificate signed by your own CA works only for clients that trust that CA. For a public site, replace it with a certificate from a publicly trusted CA. For internal services, install your root certificate in each client’s trust store (and, for Node.js, NODE_EXTRA_CA_CERTS points it at an extra CA file). Turning verification off “to make it work” removes the protection TLS exists for.

After the fix

  1. Reload the server, and confirm the reload actually happened.
  2. Re-run openssl s_client -showcerts and check Verify return code: 0 (ok).
  3. Test from a client without browser repairs: curl -sSI https://example.com on Linux (-S shows the error that -s alone would hide), or the Node.js one-liner node -e "require('https').get('https://example.com', r => { console.log(r.statusCode); r.resume(); }).on('error', e => console.log(e.code))", which prints the HTTP status when the chain verifies (any status, a redirect or a 404 included: TLS succeeded) and an error code such as UNABLE_TO_VERIFY_LEAF_SIGNATURE when it doesn’t.
  4. If you run several servers behind a load balancer, check each one: openssl s_client -connect 203.0.113.10:443 -servername example.com.

The certificate chain checker shows what the site’s public addresses send to our network, up to 4 IPv4 and 4 IPv6, and Expiry Watch checks the chain, the hostname match and the expiry date every day, with an email alert when one breaks or the date gets close. An address the checker skipped or couldn’t finish proves nothing either way, and neither tool sees each server behind a load balancer or a CDN, a hidden origin, an internal name or what a proxy shows your users, so test those from where their clients are.

Sources

The sources were checked on October 14, 2026, when the messages above were captured, except the curl, Requests and OpenSSL s_client documentation, added and checked on October 20, 2026, and the Node.js 24 documentation, added and checked on October 26, 2026, when the Node.js section’s output was captured; the earlier sources weren’t checked again that day. The OpenSSL verify, x509 and verification options documentation was added and checked on October 28, 2026, when the chain-file check’s output was captured, online and against the manual of OpenSSL 3.5.5, which ran it; Certbot’s documentation was checked again that day.