Home → 47-day TLS certificates: what changes from 2026 to 2029, and how to check your Node.js apps

47-day TLS certificates: what changes from 2026 to 2029, and how to check your Node.js apps

By · Node.js & JavaScript developer
Published October 2, 2026

For years, a TLS certificate was something you renewed once a year, maybe with a calendar reminder. That's over. Since March 15, 2026, a publicly trusted certificate can be valid for at most 200 days, and the limit keeps dropping until it reaches 47 days in 2029. At the same time, the certificate authority (CA) market is reshuffling. Chrome and Mozilla removed DigiCert's older roots in April 2026. Cloudflare issues only from Let's Encrypt, Google Trust Services, SSL.com and Sectigo. Sentry announced it will move from DigiCert to Let's Encrypt and Google Trust Services in February 2027.

This post collects what's changing, with dates from the primary sources, and then checks what it means for Node.js code. All scripts ran on node:24-slim (Node 24.21), and the trust-store checks also on Node 18, 20, 22 and 26, on October 2, 2026.

The schedule

In April 2025 the CA/Browser Forum, where CAs and browser makers set the rules for public certificates, passed Ballot SC-081v3: unanimously among the browsers (Apple, Google, Microsoft, Mozilla), and 25 to 0 among CAs, with 5 abstentions. These are the resulting tables from the current Baseline Requirements (version 2.3.0):

Certificate issuedMaximum validityDomain validation can be reused for
before 2026-03-15398 days398 days
2026-03-15 to 2027-03-14200 days (now)200 days
2027-03-15 to 2029-03-14100 days100 days
from 2029-03-1547 days10 days

The second column gets less attention, but it matters as much. When a CA checks that you control a domain (an HTTP file, a DNS record or an email), it can reuse that check for later certificates. From 2029 the check is only valid for 10 days, so for practical purposes every renewal needs a fresh domain validation. If someone currently adds a DNS record by hand once a year, that won't work any more.

Let's Encrypt, which already issues 90-day certificates, is going further:

  • Since May 13, 2026: the opt-in tlsserver profile issues 45-day certificates.
  • February 10, 2027: the default classic profile issues 64-day certificates, with a 10-day authorization reuse period.
  • February 16, 2028: the default becomes 45-day certificates with a 7-hour authorization reuse period.
  • Six-day certificates (160 hours) are available today through the shortlived profile, but they won't become the default.

Why everyone is moving to Let's Encrypt and Google Trust Services

Three changes are pushing large services away from traditional paid CAs:

  • Old roots are being removed. On April 15, 2026, Chrome and Mozilla removed DigiCert's first-generation (G1) roots. DigiCert is moving all public TLS issuance to new single-purpose G5 roots by October 15, 2026.
  • Certificates must have one purpose. Chrome's Root Program requires new public TLS certificates to be server-only: from March 15, 2027 every new leaf certificate must carry only the serverAuth extended key usage (EKU), not clientAuth. Let's Encrypt removed clientAuth on May 13, 2026, and DigiCert removes it on March 1, 2027.
  • Short lifetimes require ACME. Renewing every month or two only works with the ACME protocol, which is what Let's Encrypt and Google Trust Services were built around. Both are free.

Cloudflare's documentation now lists only Let's Encrypt, Google Trust Services, SSL.com and Sectigo (the last only for backup certificates), and recommends Google Trust Services for the widest device compatibility, because its roots are cross-signed by GlobalSign. Sentry's announcement says the switch only affects customers who pin DigiCert certificates or use clients that predate the new CAs, such as Android 5 to 7 and Java 7.

Script 1: check expiry, issuer, chain and EKU

First, see what you actually have. This script connects to each host, reads the certificate with X509Certificate, and reports when to renew using the two-thirds rule that Let's Encrypt recommends when ARI isn't available. It exits with code 1 when something needs attention, so it can run from cron or CI:

import tls from 'node:tls';

const WARN_DAYS = Number(process.env.WARN_DAYS ?? 14);
const SERVER_AUTH = '1.3.6.1.5.5.7.3.1';
const CLIENT_AUTH = '1.3.6.1.5.5.7.3.2';
const DAY = 86_400_000;

function inspect(host, port = 443) {
  return new Promise((resolve, reject) => {
    const socket = tls.connect({ host, port, servername: host, rejectUnauthorized: false }, () => {
      const leaf = socket.getPeerX509Certificate();
      const chain = [];
      for (let c = leaf; c && chain.length < 6; c = c.issuerCertificate) {
        chain.push(c.subject.match(/CN=([^\n]+)/)?.[1] ?? c.subject.match(/O=([^\n]+)/)?.[1]);
        if (c.checkIssued(c)) break; // self-signed: the root
      }
      socket.end();
      resolve({ host, authorized: socket.authorized, error: socket.authorizationError, leaf, chain });
    });
    socket.setTimeout(10_000, () => socket.destroy(new Error('timeout')));
    socket.on('error', reject);
  });
}

let problems = 0;
for (const host of process.argv.slice(2)) {
  try {
    const { authorized, error, leaf, chain } = await inspect(host);
    const from = leaf.validFromDate, to = leaf.validToDate, now = Date.now();
    const lifetime = Math.round((to - from) / DAY);
    const daysLeft = Math.floor((to - now) / DAY);
    // Renew at about two thirds of the lifetime (what Let's Encrypt recommends without ARI)
    const renewAt = new Date(from.getTime() + (to - from) * 2 / 3);
    const eku = leaf.keyUsage ?? [];
    const purposes = [eku.includes(SERVER_AUTH) && 'serverAuth', eku.includes(CLIENT_AUTH) && 'clientAuth'].filter(Boolean).join('+') || 'none';
    const issuer = leaf.issuer.match(/O=([^\n]+)/)?.[1] ?? '?';
    const status = !authorized ? `✗ ${error}` : daysLeft < WARN_DAYS ? `✗ expires in ${daysLeft} days` : now > renewAt ? '! renewal overdue' : '✓';
    if (status !== '✓') problems++;
    console.log(`${host}\n  ${status} | ${issuer} | ${lifetime}-day cert, ${daysLeft} days left, renew by ${renewAt.toISOString().slice(0, 10)} | EKU: ${purposes}\n  chain: ${chain.join(' → ')}`);
  } catch (err) {
    problems++;
    console.log(`${host}\n  ✗ ${err.code ?? err.message}`);
  }
}
process.exitCode = problems ? 1 : 0;
$ node check-certs.mjs www.codewithnodejs.com sentry.io letsencrypt.org github.com expired.badssl.com untrusted-root.badssl.com wrong.host.badssl.com
www.codewithnodejs.com
  ✓ | Google Trust Services | 90-day cert, 64 days left, renew by 2026-11-06 | EKU: serverAuth
  chain: codewithnodejs.com → WE1 → GTS Root R4
sentry.io
  ✓ | DigiCert Inc | 199-day cert, 141 days left, renew by 2026-12-16 | EKU: serverAuth
  chain: sentry.io → DigiCert Global G2 TLS RSA SHA256 2020 CA1
letsencrypt.org
  ✓ | Let's Encrypt | 90-day cert, 61 days left, renew by 2026-11-03 | EKU: serverAuth
  chain: letsencrypt.org → YE2 → Root YE → ISRG Root X2
github.com
  ✓ | Sectigo Limited | 90-day cert, 58 days left, renew by 2026-10-30 | EKU: serverAuth
  chain: github.com → Sectigo Public Server Authentication CA DV E36 → Sectigo Public Server Authentication Root E46
expired.badssl.com
  ✗ CERT_HAS_EXPIRED | COMODO CA Limited | 4-day cert, -4191 days left, renew by 2015-04-11 | EKU: serverAuth+clientAuth
  chain: *.badssl.com → COMODO RSA Domain Validation Secure Server CA → COMODO RSA Certification Authority
untrusted-root.badssl.com
  ✗ SELF_SIGNED_CERT_IN_CHAIN | BadSSL | 730-day cert, 727 days left, renew by 2028-01-29 | EKU: none
  chain: *.badssl.com → BadSSL Untrusted Root Certificate Authority
wrong.host.badssl.com
  ✗ ERR_TLS_CERT_ALTNAME_INVALID | Let's Encrypt | 90-day cert, 87 days left, renew by 2026-11-28 | EKU: serverAuth
  chain: *.badssl.com → YR1 → Root YR
exit code: 1

A few things this shows:

  • Sentry's current DigiCert certificate is valid for 199 days, just under today's 200-day maximum, and expires on February 20, 2027, about when Sentry switches CAs and a few weeks before the limit drops to 100 days.
  • The other three sites already use 90-day certificates from ACME CAs.
  • The chain shows what the server sends. Let's Encrypt's new "Generation Y" hierarchy (YE2 → Root YE) is cross-signed by ISRG Root X2, which is why it already works in older clients.
  • None of the current certificates include clientAuth any more, including Sentry's DigiCert certificate, even though DigiCert's official cutoff is March 2027. The 2015 certificate on expired.badssl.com still has both.

With rejectUnauthorized: false the script can still read and report invalid certificates. socket.authorized and socket.authorizationError tell you whether Node would have accepted them. Never use that option for actual requests.

One Node.js pitfall turned up while writing this: on Node 22 and 24, calling socket.getPeerX509Certificate() before socket.getPeerCertificate(true) makes the second call return an empty object. Node 26 returns the full certificate. The script avoids the older API and walks the chain with X509Certificate.issuerCertificate, which works on all three versions.

Script 2: ask the CA when to renew (ARI)

A fixed schedule like "renew every 60 days" breaks as soon as lifetimes change. Let's Encrypt explicitly warns that a hardcoded 60-day interval won't be enough for its 45-day certificates. The better approach is ACME Renewal Information (ARI, RFC 9773): the ACME client asks the CA for a suggested renewal window. That window can also move earlier if the CA needs to revoke certificates, as happened during past mass revocations. Both Let's Encrypt and Google Trust Services support it, and you can query it from Node.js for any certificate they issued:

import tls from 'node:tls';

// ACME directories of the two free CAs
const DIRECTORIES = {
  "Let's Encrypt": 'https://acme-v02.api.letsencrypt.org/directory',
  'Google Trust Services': 'https://dv.acme-v02.api.pki.goog/directory',
};

const b64url = (buf) => Buffer.from(buf).toString('base64url');

// Read a DER length at offset i, return [length, offset of the value]
function derLength(der, i) {
  if (der[i] < 0x80) return [der[i], i + 1];
  const n = der[i] & 0x7f;
  return [der.subarray(i + 1, i + 1 + n).reduce((len, b) => len * 256 + b, 0), i + 1 + n];
}

// Authority Key Identifier (OID 2.5.29.35): Node's X509Certificate doesn't expose it
function authorityKeyId(der) {
  const oid = der.indexOf(Buffer.from([0x06, 0x03, 0x55, 0x1d, 0x23]));
  let i = oid + 5;
  if (der[i] === 0x01) i += 3;                    // optional "critical" BOOLEAN
  [, i] = derLength(der, i + 1);                  // OCTET STRING
  [, i] = derLength(der, i + 1);                  // SEQUENCE
  if (der[i] !== 0x80) throw new Error('no keyIdentifier');
  const [len, start] = derLength(der, i + 1);     // [0] keyIdentifier
  return der.subarray(start, start + len);
}

// RFC 9773: base64url(AKI keyIdentifier) "." base64url(DER serial number bytes)
function ariCertId(cert) {
  let hex = cert.serialNumber.length % 2 ? '0' + cert.serialNumber : cert.serialNumber;
  if (parseInt(hex[0], 16) >= 8) hex = '00' + hex; // DER integers are signed
  return `${b64url(authorityKeyId(cert.raw))}.${b64url(Buffer.from(hex, 'hex'))}`;
}

function peerCertificate(host) {
  return new Promise((resolve, reject) => {
    const socket = tls.connect({ host, port: 443, servername: host }, () => {
      resolve(socket.getPeerX509Certificate());
      socket.end();
    });
    socket.on('error', reject);
  });
}

for (const host of process.argv.slice(2)) {
  const cert = await peerCertificate(host);
  const ca = cert.issuer.match(/O=([^\n]+)/)?.[1];
  if (!DIRECTORIES[ca]) { console.log(`${host}: issued by ${ca}, no free ACME/ARI endpoint to ask`); continue; }
  const { renewalInfo } = await (await fetch(DIRECTORIES[ca])).json();
  const res = await fetch(`${renewalInfo}/${ariCertId(cert)}`);
  const info = await res.json();
  console.log(`${host} (${ca}, expires ${cert.validToDate.toISOString().slice(0, 10)})`);
  console.log(`  ARI ${res.status}, Retry-After: ${res.headers.get('retry-after')}s`);
  console.log(`  suggested window: ${info.suggestedWindow?.start} → ${info.suggestedWindow?.end}`);
}
$ node ari.mjs letsencrypt.org www.codewithnodejs.com pki.goog sentry.io
letsencrypt.org (Let's Encrypt, expires 2026-12-03)
  ARI 200, Retry-After: 22227s
  suggested window: 2026-11-02T17:18:36Z → 2026-11-04T12:29:25Z
www.codewithnodejs.com (Google Trust Services, expires 2026-12-06)
  ARI 200, Retry-After: 28800s
  suggested window: 2026-11-08T21:15:41Z → 2026-11-08T22:15:41Z
pki.goog (Google Trust Services, expires 2026-12-11)
  ARI 200, Retry-After: 28800s
  suggested window: 2026-11-14T21:06:30Z → 2026-11-14T22:06:30Z
sentry.io: issued by DigiCert Inc, no free ACME/ARI endpoint to ask

Let's Encrypt suggested a two-day window that lines up with the two-thirds rule (Script 1 said "renew by 2026-11-03"). Google Trust Services returned a one-hour window. Retry-After tells clients how often to check again. You don't normally write this yourself, since it's your ACME client's job. Check whether the client you use supports ARI and has it enabled. The ARI ID is the issuer's key identifier plus the serial number. Node's X509Certificate doesn't expose the authority key identifier, which is why the script reads it from the raw DER bytes.

How often each lifetime means renewing, at two thirds of the validity period:

LifetimeRenew afterRenewals per year
398 days (until March 2026)265 days1.4
200 days (now)133 days2.7
100 days (March 2027)67 days5.5
90 days (Let's Encrypt, GTS today)60 days6.1
64 days (Let's Encrypt default, February 2027)43 days8.6
47 days (March 2029)31 days11.6
45 days (Let's Encrypt default, February 2028)30 days12.2
160 hours (Let's Encrypt shortlived)4.4 days82

Anything renewed by hand today, whether a load balancer certificate, a Java keystore, a certificate baked into a container image or an appliance, goes from once a year to roughly once a month. That's the real deadline: make sure every certificate is renewed by software before March 2027.

Script 3: mTLS with public certificates stops working

Some systems use a public TLS certificate as a client certificate, for example one service authenticating to another with mutual TLS. That relies on the clientAuth EKU, which public certificates are losing. To see what happens, a private CA issued two client certificates, one with clientAuth and one with only serverAuth, like a public certificate renewed after the cutoff:

#!/bin/sh
set -e
# A private CA for internal services
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 3650 \
  -keyout ca.key -out ca.crt -subj "/CN=Internal mTLS CA"

issue() { # name, extendedKeyUsage
  openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout $1.key -out $1.csr -subj "/CN=$1"
  printf "basicConstraints=CA:FALSE\nextendedKeyUsage=%s\nsubjectAltName=DNS:%s\n" "$2" "$1" > $1.ext
  openssl x509 -req -in $1.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 30 -extfile $1.ext -out $1.crt
}
issue server serverAuth
issue billing-service clientAuth          # what a client certificate should have
issue orders-service serverAuth           # like a public TLS certificate issued after the clientAuth removal
billing-service: TLS Web Client Authentication
orders-service: TLS Web Server Authentication

A Node.js HTTPS server that requires client certificates from that CA:

import https from 'node:https';
import fs from 'node:fs';

https.createServer({
  key: fs.readFileSync('server.key'),
  cert: fs.readFileSync('server.crt'),
  ca: fs.readFileSync('ca.crt'),   // trust only our private CA for client certificates
  requestCert: true,
  rejectUnauthorized: true,
}, (req, res) => {
  res.end(`hello ${req.socket.getPeerX509Certificate().subject}\n`);
}).on('tlsClientError', (err) => console.log(`server: rejected client: ${err.code ?? err.message}`))
  .listen(8443);
import https from 'node:https';
import fs from 'node:fs';

for (const name of ['billing-service', 'orders-service']) {
  await new Promise((resolve) => {
    https.get('https://server:8443/', {
      key: fs.readFileSync(`${name}.key`),
      cert: fs.readFileSync(`${name}.crt`),
      ca: fs.readFileSync('ca.crt'),
    }, (res) => {
      res.setEncoding('utf8');
      res.on('data', (d) => console.log(`${name}: ${res.statusCode} ${d.trim()}`)).on('end', resolve);
    }).on('error', (err) => { console.log(`${name}: ${err.code ?? ''} ${err.message}`); resolve(); });
  });
}
billing-service: 200 hello CN=billing-service
orders-service: ECONNRESET socket hang up
server: rejected client: ECONNRESET

The certificate without clientAuth was rejected, but the error doesn't say why. On both sides it's a plain connection reset. To see the reason, set rejectUnauthorized: false on the server temporarily and read req.socket.authorizationError:

if (!req.socket.authorized) return res.writeHead(403).end(`rejected: ${req.socket.authorizationError}\n`);
billing-service: 200 hello CN=billing-service
orders-service: 403 rejected: INVALID_PURPOSE

INVALID_PURPOSE means OpenSSL found a valid certificate whose EKU doesn't allow client authentication. If your mTLS setup depends on public certificates, it breaks at the first renewal after your CA drops clientAuth, which for Let's Encrypt has already happened. The fix is the one shown here: issue client certificates from a private CA that you control (step-ca, HashiCorp Vault, AWS Private CA or plain OpenSSL), where you set the EKU yourself.

Script 4: which CAs Node.js trusts

Node.js doesn't use the operating system's trust store by default. It ships its own copy of Mozilla's root store, so which roots you trust depends on your Node version, not on the server it runs on. Checking a few roots that matter for the current changes:

import tls from 'node:tls';
import { X509Certificate } from 'node:crypto';

const names = tls.rootCertificates.map((pem) => new X509Certificate(pem).subject.match(/CN=([^\n]+)/)?.[1]);
for (const root of ['ISRG Root X1', 'ISRG Root X2', 'GTS Root R1', 'GTS Root R4', 'GlobalSign Root CA',
  'DigiCert Global Root CA', 'DigiCert Global Root G2', 'DigiCert TLS RSA4096 Root G5']) {
  console.log(names.includes(root) ? '✓' : '✗', root);
}
RootNode 18.20Node 20.20Node 22.23Node 24.21Node 26.10
Total roots150144119121121
ISRG Root X1, X2 (Let's Encrypt)✓✓✓✓✓
GTS Root R1, R4 (Google)✓✓✓✓✓
GlobalSign Root CA (R1)✓✗✗✗✗
DigiCert Global Root CA (G1)✓✓✗✗✗
DigiCert Global Root G2✓✓✓✓✓
DigiCert TLS RSA4096 / ECC P384 Root G5✓✓✓✓✓

Two practical consequences:

  • Node 22 and later no longer trust DigiCert's G1 root, following Mozilla. DigiCert runs demo servers for each root, and the G1 one shows the effect:
v20.20.2 global-root-ca.chain-demos.digicert.com → OK, root: DigiCert Global Root CA
v20.20.2 global-root-g2.chain-demos.digicert.com → OK, root: DigiCert Global Root G2
v22.23.3 global-root-ca.chain-demos.digicert.com → SELF_SIGNED_CERT_IN_CHAIN
v22.23.3 global-root-g2.chain-demos.digicert.com → OK, root: DigiCert Global Root G2
v24.21.0 global-root-ca.chain-demos.digicert.com → SELF_SIGNED_CERT_IN_CHAIN
v24.21.0 global-root-g2.chain-demos.digicert.com → OK, root: DigiCert Global Root G2

The same code that works on Node 20 fails on 22 and 24 for a server chained to G1. Depending on whether the server also sends the root, the error is SELF_SIGNED_CERT_IN_CHAIN (as here) or UNABLE_TO_GET_ISSUER_CERT_LOCALLY. If an outgoing request to an internal or partner API breaks right after a Node upgrade, check its chain with Script 1.

  • New roots arrive only with Node updates. No Node version includes Let's Encrypt's new Root YE yet. letsencrypt.org still verified on Node 18, 24 and 26 because its chain is cross-signed by ISRG Root X2. Keeping Node up to date keeps your trust store up to date.

Trusting your own CAs

For a private CA, such as the mTLS CA above or a company proxy, you have two options. This test ran against an HTTPS server whose certificate came from the private CA, using tls.getCACertificates() (available in Node 22.15 and 24) to count what's trusted:

node:24-slim, defaults:
bundled=121 system=0 extra=0 default=121 | fetch letsencrypt.org: 200 | fetch private server: UNABLE_TO_VERIFY_LEAF_SIGNATURE
node:24-slim, --use-system-ca:
bundled=121 system=0 extra=0 default=121 | fetch letsencrypt.org: 200 | fetch private server: UNABLE_TO_VERIFY_LEAF_SIGNATURE
node:24-slim, NODE_EXTRA_CA_CERTS=ca.crt:
bundled=121 system=0 extra=1 default=122 | fetch letsencrypt.org: 200 | fetch private server: 200
node:24 with the CA added to the OS store (update-ca-certificates), no flag:
bundled=121 system=453 extra=0 default=121 | fetch letsencrypt.org: 200 | fetch private server: UNABLE_TO_VERIFY_LEAF_SIGNATURE
node:24 with the CA added to the OS store, --use-system-ca:
bundled=121 system=453 extra=0 default=574 | fetch letsencrypt.org: 200 | fetch private server: 200
  • NODE_EXTRA_CA_CERTS=/path/ca.crt adds certificates to the bundled roots, for both https and fetch. It works in any image.
  • --use-system-ca (or NODE_USE_SYSTEM_CA=1) adds the OS store to the bundled roots. It was added in Node 23.8. It also worked on Node 22.23 in this test. But the node:*-slim images have no ca-certificates package, so the system store is empty there (OpenSSL even prints Cannot open directory /etc/ssl/certs), and the flag does nothing until you install it.
  • Without either option, Node ignores the OS store completely, even when the CA is installed there. That surprises people who added a corporate CA with update-ca-certificates and wonder why curl works and Node doesn't.
  • Passing a ca option to https.request() replaces the default roots for that request instead of adding to them.

What to do now

  1. Find every certificate that isn't renewed automatically. CDNs and managed platforms (Cloudflare, Vercel, cloud load balancers with managed certificates) already handle this. Look for certificates you uploaded yourself: load balancers, Kubernetes secrets, Java keystores, VPN appliances, files copied into Docker images, OV/EV certificates bought yearly. From March 2027 they need renewing about every two months.
  2. Use ACME everywhere, with ARI if your client supports it, and otherwise renew at two thirds of the lifetime, not on a fixed interval. Automate DNS validation through your DNS provider's API before the 10-day reuse limit in 2029.
  3. Monitor expiry independently, for example by running Script 1 daily with WARN_DAYS=14 and alerting on a non-zero exit code. Automation fails silently.
  4. Don't pin certificates or intermediates, and always serve the full chain. Providers will switch CAs (Sentry's change is exactly this), and intermediates now rotate frequently.
  5. Move mTLS client certificates to a private CA if they come from a public CA today.
  6. Check old clients: Android 7 and older, Java 7, embedded devices and old Node versions. Run your important outgoing connections against each Node version you deploy.
  7. Keep Node.js updated, and use NODE_EXTRA_CA_CERTS or --use-system-ca (with ca-certificates installed) for private CAs.

Sources & further reading

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.