Guide

eMudhra CA compromise: check whether your servers still trust the bad certificates

By Aman Singh · Published · 9 min read

In October 2026 the private keys of eight eMudhra intermediate certificate authorities turned out to be recoverable from their public certificates. Anyone could have used them to issue a trusted HTTPS certificate for any website. Firefox, Chrome, Apple and Microsoft blocked the intermediates within days, so browsers are protected.

Servers are a different story. A Node.js API calling Stripe, a Python script, a container built in September: each trusts whatever certificate list it was built with. This guide shows which runtimes still trust the bad certificates, with results I measured on 24 October 2026, and gives a short script that checks your own.

Updating Node.js for this? The same releases fix a Brotli flaw: Node2Shell check and patch →

Browsers were fixed within days. The risk that remains is your own outbound HTTPS: servers, containers, scripts and CI jobs with an old trust store.

What happened

Details
WhatPrivate keys of eight eMudhra intermediate CA certificates were compromised; six are TLS issuing CAs
WhyThe keys were trivially weak: researchers describe values small enough to recover from the public certificate
SinceThe certificates were created in April 2025, so the keys could have been recovered from then on
Reported17 October 2026, to the browser root programs
BlockedFirefox and Chrome 17 October; Microsoft 20 October; Apple 19 to 22 October (sources differ)
ExploitationNo malicious use confirmed as of 21 October

Mozilla's summary is blunt: the keys "could allow attackers to issue fraudulent HTTPS certificates for websites they do not control." eMudhra, an Indian certificate authority, has since announced that it is leaving the global SSL/TLS certificate business.

Who is still at risk

Using a fake certificate needs a position on the network path: a compromised proxy or router, public Wi-Fi, a hijacked DNS answer. Against a browser that no longer works. Against software with an old trust store it still does. That means:

  • Backend services making outbound HTTPS calls to payment providers, APIs and webhooks.
  • Containers built before the fix. Patching the host does not change images that are already built.
  • Scripts, CI runners and internal tools on older Node.js, Python, Go or Java.
  • Libraries with their own bundled certificate file, which ignore the system store entirely.

What I found when testing

The researchers run test servers whose certificates chain through the compromised intermediates: two logged in Certificate Transparency and two that are not. A safe client refuses all four. Results on Windows 11, 24 October 2026:

ClientCompromised chains trusted
Node.js 24.22.0 and 26.12.0 (before the fix)All 4
Node.js 24.22.0 with --use-system-caAll 4
Node.js 20.20.2 (end of life, no fix)2 of 4
Node.js 24.22.1 and 26.12.1None (CERT_REVOKED)
Python with certifi 2026.07.221 of the 2 tested
Python with the Windows store1 of the 2 tested
Python with certifi 2026.10.23None
curl on Windows (Schannel)None (CRYPT_E_REVOKED)
  • Node.js does not check Certificate Transparency, so the "not in CT" chains were trusted just like the logged ones. Browsers that enforce CT get an extra layer of protection Node.js does not have.
  • The Node.js fix is a blocklist. 26.12.1, 24.22.1 and 22.23.4 update the root certificates and add a built-in list of blocked keys (pull request #66871), which is why the patched builds report CERT_REVOKED.
  • The operating system store did not save unpatched Node.js. Windows itself refused the chains as revoked, but Node.js 24.22.0 with --use-system-ca trusted all four. Do not rely on the system store as a workaround; update Node.js.

Check your own runtime

This script connects to the four test servers with the runtime's own trust store, after checking that HTTPS works at all, so a network failure is never mistaken for a pass. Run it with the same node your app uses, ideally inside the container or on the server.

emudhra-check.mjs
#!/usr/bin/env node
// emudhra-check.mjs: does this Node.js still trust the eight compromised
// eMudhra intermediate CAs (October 2026)? It connects to the disclosure
// site's test hosts, whose certificates chain through those intermediates,
// using this Node.js trust store. A patched runtime must refuse all of them.
//   node emudhra-check.mjs            # Node's bundled CA store
//   node --use-system-ca emudhra-check.mjs   # the operating system's store
// Exit code: 0 = all refused, 1 = at least one trusted, 2 = could not test.
import https from "node:https";
import path from "node:path";
import { fileURLToPath } from "node:url";

export const TEST_HOSTS = [
  { host: "dv-g3b-emudhra.we-have-a-problem.com", what: "compromised intermediate, logged in CT" },
  { host: "dv-g3b-aaa-emudhra.we-have-a-problem.com", what: "same, via the AAA Certificate Services cross-sign" },
  { host: "dv-g3b-noct-emudhra.we-have-a-problem.com", what: "compromised intermediate, not in CT" },
  { host: "dv-g3b-aaa-noct-emudhra.we-have-a-problem.com", what: "cross-signed, not in CT" },
];
const CONTROL = "www.google.com";

/** Connects with the default trust store; resolves to { ok, code }. Never rejects. */
export function probe(host, timeoutMs = 10_000) {
  return new Promise((resolve) => {
    const req = https.get({ host, path: "/", timeout: timeoutMs, agent: false }, (res) => {
      res.resume();
      resolve({ ok: true, code: `HTTP ${res.statusCode}` });
    });
    req.on("timeout", () => req.destroy(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" })));
    req.on("error", (err) => resolve({ ok: false, code: err.code ?? err.message }));
  });
}

const TLS_REJECTIONS = new Set([
  "CERT_REVOKED",
  "UNABLE_TO_GET_ISSUER_CERT",
  "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
  "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
  "SELF_SIGNED_CERT_IN_CHAIN",
  "CERT_UNTRUSTED",
  "CERT_HAS_EXPIRED",
]);

/** Classifies one probe: trusted (bad), refused (good) or unknown (network problem). */
export function classify(result) {
  if (result.ok) return "TRUSTED";
  if (TLS_REJECTIONS.has(result.code) || /CERT|ISSUER|SIGNATURE/.test(result.code)) return "refused";
  return "unknown";
}

export function verdict(classes) {
  if (classes.includes("TRUSTED")) return 1;
  if (classes.includes("unknown")) return 2;
  return 0;
}

if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
  const systemCa = process.execArgv.includes("--use-system-ca");
  console.log("eMudhra intermediate CA check (October 2026)");
  console.log(`node:   ${process.version} (${systemCa ? "operating system CA store" : "bundled CA store"})`);
  const control = await probe(CONTROL);
  console.log(`control ${CONTROL}: ${control.ok ? "reachable" : `FAILED (${control.code})`}`);
  if (!control.ok) {
    console.log("No working HTTPS connection, so the results below would mean nothing.");
    process.exit(2);
  }
  const classes = [];
  for (const t of TEST_HOSTS) {
    const r = await probe(t.host);
    const c = classify(r);
    classes.push(c);
    console.log(`${c.padEnd(8)} ${r.code.padEnd(34)} ${t.host}  (${t.what})`);
  }
  const v = verdict(classes);
  console.log("");
  console.log(
    v === 0
      ? "OK: this runtime refuses the compromised intermediates."
      : v === 1
        ? "ACTION NEEDED: this runtime trusts at least one compromised chain. Update Node.js (26.12.1, 24.22.1, 22.23.4) and the system ca-certificates."
        : "Could not decide: some test hosts failed for network reasons. Retry, or open them in a browser.",
  );
  process.exit(v);
}
Terminal
node emudhra-check.mjs                    # Node's bundled store
node --use-system-ca emudhra-check.mjs    # the operating system's store
echo "exit code: $?"                       # 0 refused, 1 trusted, 2 network problem
Output on Node.js 24.22.1
eMudhra intermediate CA check (October 2026)
node:   v24.22.1 (bundled CA store)
control www.google.com: reachable
refused  CERT_REVOKED   dv-g3b-emudhra.we-have-a-problem.com
refused  CERT_REVOKED   dv-g3b-aaa-emudhra.we-have-a-problem.com
refused  CERT_REVOKED   dv-g3b-noct-emudhra.we-have-a-problem.com
refused  CERT_REVOKED   dv-g3b-aaa-noct-emudhra.we-have-a-problem.com

OK: this runtime refuses the compromised intermediates.
Output on Node.js 24.22.0
node:   v24.22.0 (bundled CA store)
TRUSTED  HTTP 200   dv-g3b-emudhra.we-have-a-problem.com
TRUSTED  HTTP 200   dv-g3b-aaa-emudhra.we-have-a-problem.com
TRUSTED  HTTP 200   dv-g3b-noct-emudhra.we-have-a-problem.com
TRUSTED  HTTP 200   dv-g3b-aaa-noct-emudhra.we-have-a-problem.com

ACTION NEEDED: this runtime trusts at least one compromised chain.
Show the tests (emudhra-check.test.mjs, 5 passing)
emudhra-check.test.mjs
// node --test emudhra-check.test.mjs  (no network needed)
import { test } from "node:test";
import assert from "node:assert/strict";
import { classify, verdict, TEST_HOSTS } from "./emudhra-check.mjs";

test("classify: a completed request means the chain was trusted", () => {
  assert.equal(classify({ ok: true, code: "HTTP 200" }), "TRUSTED");
});

test("classify: certificate errors count as refused", () => {
  for (const code of ["CERT_REVOKED", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", "SELF_SIGNED_CERT_IN_CHAIN", "ERR_TLS_CERT_ALTNAME_INVALID"]) {
    assert.equal(classify({ ok: false, code }), "refused", code);
  }
});

test("classify: network failures are unknown, not a pass", () => {
  for (const code of ["ENOTFOUND", "ECONNRESET", "ETIMEDOUT", "ECONNREFUSED"]) {
    assert.equal(classify({ ok: false, code }), "unknown", code);
  }
});

test("verdict: any trusted host fails; network gaps are inconclusive", () => {
  assert.equal(verdict(["refused", "refused", "refused", "refused"]), 0);
  assert.equal(verdict(["refused", "TRUSTED", "refused", "refused"]), 1);
  assert.equal(verdict(["refused", "unknown", "refused", "refused"]), 2);
  assert.equal(verdict(["TRUSTED", "unknown"]), 1);
});

test("tests both the CT-logged and the non-CT chains", () => {
  assert.equal(TEST_HOSTS.length, 4);
  assert.equal(TEST_HOSTS.filter((t) => t.host.includes("noct")).length, 2);
});

For anything else, the same test servers work with curl or a one-line Python check:

Terminal
# Any tool that uses your trust store: a certificate error is the good result
curl https://dv-g3b-emudhra.we-have-a-problem.com/
curl https://dv-g3b-aaa-noct-emudhra.we-have-a-problem.com/

# Python: which certifi does this environment use?
python -c "import certifi; print(certifi.__version__)"   # want 2026.10.19 or later

Update your trust stores

Terminal
# Debian / Ubuntu
sudo apt-get update && sudo apt-get install --only-upgrade ca-certificates

# Alpine (also in Dockerfiles)
apk update && apk upgrade ca-certificates ca-certificates-bundle

# Python
pip install -U "certifi>=2026.10.19"

# Node.js: 26.12.1, 24.22.1 or 22.23.4 (see the Node.js guide), then restart
ComponentFixed in
Node.js26.12.1, 24.22.1, 22.23.4
Python certifi2026.10.19
Go (CVE-2026-111943)1.27.3, 1.26.10
NSS / FirefoxNSS 3.131.1; Firefox 158.0.1, ESR 153.5.1, ESR 115.43.1
Ubuntu ca-certificates20260816ubuntu0.22.04.1, 0.24.04.1, 0.26.04.1
Alpine ca-certificates20261020-r0
curl CA bundle2026-10-20
Electron44.9.1, 43.8.1, 42.11.16
Java (JDK cacerts)Not confirmed: check your JDK vendor

Versions beyond Node.js and certifi are from Endor Labs' tracking list; confirm with each vendor. Then:

  1. 1
    Rebuild and redeploy every container from patched base images, and restart long-running processes.
  2. 2
    Look for bundled certificate files (.pem bundles inside dependencies) that ignore the system store, and point those libraries at an up-to-date store instead.
  3. 3
    Run the checker again where your code actually runs, not only on your laptop.

If your own site uses an eMudhra certificate

Certificates issued by the eight intermediates are now blocked in browsers, so a site still serving one will show security errors. Check which authority issued yours:

Terminal
# Which CA issued the certificate your site serves?
openssl s_client -connect www.example.com:443 -servername www.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -issuer -enddate

If the issuer mentions emSign or eMudhra, compare it with the list of affected intermediates on the disclosure site, and replace the certificate with one from another certificate authority. The free launch readiness check also confirms that your site loads over HTTPS without errors.

Common questions

What is the eMudhra CA compromise?+
In October 2026 the private keys of eight eMudhra intermediate certificate authorities were found to be recoverable from their public certificates, which would let anyone issue trusted HTTPS certificates for any site. Browsers blocked the intermediates between 17 and 22 October.
Am I affected if I only use a browser?+
Up-to-date Firefox, Chrome, Safari and Edge already block the compromised intermediates. The remaining risk is in software with its own trust store: servers, containers, scripts and older apps.
How do I check if my Node.js app trusts the compromised certificates?+
Run the checker in this guide with the node binary your app uses. Node.js 26.12.1, 24.22.1 and 22.23.4 refuse the test chains with CERT_REVOKED; earlier versions trusted them in testing.
Does updating ca-certificates fix Node.js?+
Not by itself. Node.js uses its own bundled certificate list by default, so update Node.js too. In testing, even --use-system-ca on an unpatched Node.js trusted the compromised chains.
Was the eMudhra compromise exploited?+
No malicious use had been confirmed as of 21 October 2026. Because the keys were recoverable since April 2025, review logs for clients that do not enforce Certificate Transparency if they handle credentials.

Want your servers checked?

I update Node.js and Python runtimes, base images and certificate stores, and verify the result where the code runs. See Node.js, Python and Next.js bug fixing.

Sources

Next steps

Keep reading

Need a hand with this?

Tell me what you are working on. Free scoping call, fixed quote within 48 hours.

Book a call

← All guides