Two Node.js release lines change phase this month. Node.js 24 moves from Active LTS to Maintenance on 20 October 2026, and Node.js 26 is scheduled to become the new Active LTS line on 28 October. Neither change is urgent for security. Maintenance lines keep getting security fixes, and 24 is supported until April 2028. The urgent cases are elsewhere: services still on Node.js 20, which reached end of life on 30 April, and pins that skipped this year's security releases.
This guide is for teams that run Node.js in production, in containers or in CI. It covers what each phase means for security fixes, what 2026's security releases show about which lines get patched, which version to plan for, and a script that lists every Node.js version a repository pins and flags the ones that are end of life or behind on security releases.
The short answer
Node.js 24 entering Maintenance on 20 October 2026 doesn't change its security coverage: maintenance lines get critical bug fixes and security updates until end of life, which for 24 is 30 April 2028. Node.js 22 is also in Maintenance and ends on 30 April 2027, so plan its upgrade now. Node.js 20 has been end of life since 30 April 2026 and got none of the 23 CVE fixes in the June and July security releases. Node.js 26 becomes Active LTS on 28 October and runs to April 2029. If you're on 20, move first. If you're on 22, schedule 24 or 26. If you're on 24, test 26 at your own pace. Either way, make sure each pin includes the latest security release for its line.
What the phases mean
Node.js has three phases, defined by its Release Working Group:
| Phase | What lands | Lines in it on 29 October 2026 |
|---|---|---|
| Current | Most non-breaking changes from the main branch | None. The schedule lists 27 as an alpha from 28 October, first release April 2027 |
| Active LTS | New features, bug fixes and audited updates | 26 |
| Maintenance | Critical bug fixes and security updates | 22, 24 |
| End of life | Nothing | 20, 25 and older |
Even-numbered lines get 12 months of Active LTS and then 18 months of Maintenance. Odd-numbered lines, like 25, never become LTS and reach end of life within months.
| Line | Active LTS from | Maintenance from | End of life |
|---|---|---|---|
| 20 (Iron) | 24 Oct 2023 | 22 Oct 2024 | 30 Apr 2026 |
| 22 (Jod) | 29 Oct 2024 | 21 Oct 2025 | 30 Apr 2027 |
| 24 (Krypton) | 28 Oct 2025 | 20 Oct 2026 | 30 Apr 2028 |
| 26 | 28 Oct 2026 | 20 Oct 2027 | 30 Apr 2029 |
The project notes that dates can change, with at least 14 days' notice.
What 2026's security releases show
Node.js has published four security releases so far in 2026. We counted the CVEs in each from the release announcements and the Node.js Security Working Group's vulnerability data, which records the vulnerable and patched versions for every core CVE:
| Release | Versions | CVEs | Rated high |
|---|---|---|---|
| 12 January 2026 (announced as the December 2025 release) | 20.20.0, 22.22.0, 24.13.0, 25.3.0 | 8 | 3 |
| 24 March 2026 | 20.20.2, 22.22.2, 24.14.1, 25.8.2 | 9, including a second fix for CVE-2026-21637 | 2 |
| 17 June 2026 | 22.23.0, 24.17.0, 26.3.1 | 12 | 2 |
| 28 July 2026 | 22.23.2, 24.18.1, 26.5.1 | 11 | 3 |
| Total | 39 distinct CVEs, 40 fixes | 10 |
March's fix for CVE-2026-21637 completed January's and reused the same ID, and the Working Group's data file records it only once, under March. Counting by distinct CVE, the figures below use 39.
Two things stand out.
Maintenance lines are patched like active ones. Node.js 22 was in Maintenance for all of 2026 and received fixes for 34 of the 39 CVEs. Node.js 24, in Active LTS, received 36. Four of the five that 22 didn't get aren't listed as affecting 22 at all. The fifth is a data inconsistency covered below. So 24 moving to Maintenance on 20 October is not a reason to rush to 26.
End of life means no fixes and no verdict. Node.js 20 was patched in January and March, and its last release was 20.20.2 on 24 March. The June and July releases fixed 23 CVEs, and the Security Working Group's data lists 20.x as vulnerable for none of them, because an end-of-life line isn't assessed. Twenty-one of those 23 list 22.x, the closest supported line. Treat 20 as exposed to most of them, even though no scanner reading this data will say so. Node.js 25 shows the same pattern: it reached end of life on 1 June, and the June release went to 26 instead.
One small data note: CVE-2026-58041 (node:sqlite) says in its description that it affects 22.x, but its vulnerable range lists only 24.x and 26.x, and its patched versions are only 24.18.1 and 26.5.1. If you run 22 and use node:sqlite's tag store, take the description as the safer reading.
Which version to run
- On 20: move now. Twenty-four is the smaller jump today. Twenty-six gives you the longest runway, to April 2029, but it is the bigger change.
- On 22: you have until 30 April 2027. Moving to 24 gives you a year of extra support for a smaller change. Moving to 26 skips one upgrade cycle.
- On 24: nothing is forced until April 2028. Test 26 when its LTS releases settle and move when it suits your release calendar.
- On 26 already: after 28 October you're on Active LTS. Keep up with the 26.x releases.
Going from 24 to 26 is a semver-major jump. The 26.0.0 release notes list V8 14.6, Undici 8, Temporal enabled by default, and several removals, including http.Server.prototype.writeHeader(), the legacy _stream_* modules and the --experimental-transform-types flag, plus a runtime deprecation of module.register(). Read the full list before you switch, and run your test suite on 26 in CI first.
Floating tags deserve a look too. On 18 October the Docker official image tags node:lts and node:lts-slim point at 24.22.0. They follow the newest LTS line, so an image built from them will change major version without anyone deciding to. Pin a major (node:24-slim) and upgrade on purpose.
Find every Node.js version you pin
A typical service pins Node.js in four or five places that drift apart: .nvmrc, package.json engines, the Dockerfile and the CI matrix. This script finds them, reports each line's phase on a given date with the next transition, and flags exact versions that are missing their line's latest security release. It reads the release schedule and the Node.js release index live and uses only the Python standard library:
#!/usr/bin/env python3
"""List every Node.js version a repository pins, with its release phase and missing security releases.
Usage: python3 node_pins.py REPO_DIR [--date YYYY-MM-DD]
Reads .nvmrc, .node-version, .tool-versions, package.json (engines, volta),
Dockerfiles and GitHub Actions workflows. Standard library only.
"""
import datetime
import json
import os
import re
import sys
import urllib.request
SCHEDULE = "https://raw.githubusercontent.com/nodejs/Release/main/schedule.json"
DIST = "https://nodejs.org/dist/index.json"
CODENAMES = {"hydrogen": 18, "iron": 20, "jod": 22, "krypton": 24}
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": "node-pins-check"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def find_pins(root):
pins = []
for dirpath, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if d not in ("node_modules", ".git")]
for f in files:
path = os.path.join(dirpath, f)
rel = os.path.relpath(path, root)
try:
text = open(path, encoding="utf-8", errors="replace").read()
except OSError:
continue
if f in (".nvmrc", ".node-version"):
pins.append((rel, text.strip().splitlines()[0] if text.strip() else ""))
elif f == ".tool-versions":
pins += [(rel, m) for m in re.findall(r"^nodejs\s+(\S+)", text, re.M)]
elif f == "package.json":
try:
pkg = json.loads(text)
except ValueError:
continue
if isinstance(pkg.get("engines"), dict) and pkg["engines"].get("node"):
pins.append((rel + " engines", pkg["engines"]["node"]))
if isinstance(pkg.get("volta"), dict) and pkg["volta"].get("node"):
pins.append((rel + " volta", pkg["volta"]["node"]))
elif f.startswith("Dockerfile") or f.endswith(".dockerfile"):
pins += [(rel, m) for m in re.findall(r"^FROM\s+(?:--\S+\s+)?\S*node:([^\s@]+)", text, re.M | re.I)]
elif os.sep + os.path.join(".github", "workflows") + os.sep in os.sep + rel and f.endswith((".yml", ".yaml")):
values, refs = [], []
for m in re.findall(r"node-version:\s*(\[[^\]]*\]|\S.*)", text):
if "${{" in m:
refs.append(m.strip())
else:
values += re.findall(r"[^\s\[\],'\"]+", m)
pins += [(rel, v) for v in values]
# a matrix under another key name (or a version from an input) can't be read here
if refs and not values:
pins += [(rel, r) for r in refs]
return sorted(set(pins))
def major_of(spec):
s = spec.lower().strip("'\"v ")
if s.startswith("lts/"):
name = s[4:]
return ("latest LTS", None) if name in ("*", "-") else (name, CODENAMES.get(name))
if s in ("node", "latest", "current") or s.startswith(("latest", "current")):
return ("latest", None)
if s.startswith(("lts", "alpine", "slim", "bookworm", "bullseye", "trixie")):
return ("latest LTS", None)
# for ranges like ">=18" or "^20.11.0 || ^22", report the lowest major allowed
majors = [int(m) for m in re.findall(r"(?<![\d.])(\d{1,2})(?=\.|\b)", s)]
return (spec, min(majors)) if majors else (spec, None)
def main(argv):
root = argv[0]
today = datetime.date.today()
if "--date" in argv:
today = datetime.date.fromisoformat(argv[argv.index("--date") + 1])
schedule, dist = fetch(SCHEDULE), fetch(DIST)
latest_sec = {}
for r in dist: # newest first
mj = int(r["version"][1:].split(".")[0])
if r["security"] and mj not in latest_sec:
latest_sec[mj] = (r["version"], r["date"])
def phase(mj):
s = schedule.get(f"v{mj}")
if not s:
return "unknown line", None
d = {k: datetime.date.fromisoformat(v) for k, v in s.items() if k != "codename"}
if today >= d["end"]:
return f"END OF LIFE since {d['end']}, no further security releases", None
if "lts" in d and today >= d.get("maintenance", d["end"]):
ph, nxt = "maintenance LTS", ("end of life", d["end"])
elif "lts" in d and today >= d["lts"]:
ph, nxt = "active LTS", ("maintenance", d["maintenance"])
else:
ph = "current (not LTS)" if "lts" not in d else "current"
nxt = ("LTS", d["lts"]) if "lts" in d else ("end of life", d["end"])
return ph, nxt
print(f"Node.js pins in {root} (phases as of {today})")
for where, spec in find_pins(root):
if spec.startswith("${{"):
print(f" {where}: {spec} -> not resolved; check this workflow's matrix or inputs by hand")
continue
label, mj = major_of(spec)
if mj is None:
print(f" {where}: {spec} -> floating ({label}); pin a major so upgrades are deliberate")
continue
ph, nxt = phase(mj)
line = f" {where}: {spec} -> Node.js {mj}, {ph}"
if nxt:
days = (nxt[1] - today).days
line += f"; {nxt[0]} on {nxt[1]}" + (f" (in {days} days)" if days <= 180 else "")
print(line)
exact = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)(?:-\S+)?", spec.strip("'\" "))
if exact and mj in latest_sec:
have = tuple(int(x) for x in exact.groups())
want_v, want_d = latest_sec[mj]
if have < tuple(int(x) for x in want_v[1:].split(".")):
print(f" missing security release {want_v} ({want_d})")
if __name__ == "__main__":
if not sys.argv[1:]:
sys.exit(__doc__)
main(sys.argv[1:])
On a small fixture tree with three services:
$ python3 node_pins.py fx --date 2026-10-18
Node.js pins in fx (phases as of 2026-10-18)
api/.github/workflows/ci.yml: 22.x -> Node.js 22, maintenance LTS; end of life on 2027-04-30
api/.github/workflows/ci.yml: 24.x -> Node.js 24, active LTS; maintenance on 2026-10-20 (in 2 days)
api/.nvmrc: v20.19.2 -> Node.js 20, END OF LIFE since 2026-04-30, no further security releases
missing security release v20.20.2 (2026-03-24)
api/Dockerfile: 20.19.2-alpine -> Node.js 20, END OF LIFE since 2026-04-30, no further security releases
missing security release v20.20.2 (2026-03-24)
api/package.json engines: >=20 -> Node.js 20, END OF LIFE since 2026-04-30, no further security releases
web/.node-version: 24.18.0 -> Node.js 24, active LTS; maintenance on 2026-10-20 (in 2 days)
missing security release v24.18.1 (2026-07-28)
web/Dockerfile: lts-slim -> floating (latest LTS); pin a major so upgrades are deliberate
worker/.github/workflows/release.yml: ${{ matrix.node }} -> not resolved; check this workflow's matrix or inputs by hand
worker/.tool-versions: 26.12.0 -> Node.js 26, current; LTS on 2026-10-28 (in 10 days)
worker/package.json engines: ^22.12.0 || ^24 -> Node.js 22, maintenance LTS; end of life on 2027-04-30
worker/package.json volta: 22.23.2 -> Node.js 22, maintenance LTS; end of life on 2027-04-30
The api service is the one to fix. Its Dockerfile and .nvmrc are on an end-of-life line and also missed the last two security releases 20 ever got, while its CI matrix tests only 22 and 24. That's a common pattern: CI moves forward, and production stays where it was. The web service is on a supported line but one security release behind. The worker service is fine: it already runs 26, and its package.json allows 22 or 24 for development. Its release workflow takes the version from a matrix key the script doesn't read, so the script says so rather than skipping it silently.
The script checks pins, not what's actually running. Running containers and images in a registry can be older than the repository says. Here's Safeguard's interactive walkthrough of scanning container images and seeing what to upgrade in each layer:
What to do
- Run the pin check on every repository and list each service's line. Anything on 20 or older goes to the top.
- Make every pin agree.
.nvmrc, engines, Dockerfile and CI should name the same major, and CI should test the version production runs. - Take the latest release on your line. On 18 October those are 22.23.3, 24.22.0 and 26.12.0, each newer than the last security release for its line. Our guide to tracking Node.js vulnerabilities covers how to follow security releases.
- Plan 22's exit before 30 April 2027, and put 26 into your CI matrix now so the switch is a known quantity.
- Replace floating image tags with a pinned major and an automated update for patch releases.
- If you must stay on 20 for now, keep the service off untrusted input where you can, and record it as an accepted risk with an end date. The fixes for the June and July CVEs won't arrive.
The fair view
Node.js's schedule is predictable and generous. Every LTS line gets two and a half years, dates are published years ahead, and 2026's data shows maintenance lines really do get security fixes. For most teams, 20 October changes nothing.
The risk comes from the parts no one owns. End-of-life lines vanish from vulnerability data instead of being marked affected, floating tags move without a decision, and pins scattered across five files let production lag behind CI. None of that is a Node.js problem. A short script and a rule that every pin names a supported line fix most of it.
Check yourself
Node.js 24 enters Maintenance on 20 October 2026. Does it still get security fixes?
Yes. Maintenance lines get critical bug fixes and security updates until end of life, which for 24 is 30 April 2028. In 2026, Node.js 22 was in Maintenance all year and received fixes for 34 of 39 CVEs.
Your scanner shows no Node.js runtime findings for a service on 20.20.2. Why might that be misleading?
Node.js 20 reached end of life on 30 April 2026, and the vulnerability data for later releases doesn't assess it. None of the 23 CVEs fixed in June and July list 20.x, though 21 of them list 22.x. No finding here means not assessed, not safe.
Your Dockerfile uses node:lts-slim. What happens after 28 October?
The tag follows the newest LTS line, so once Node.js 26 is LTS your next build can move from 24 to 26 without anyone choosing to. Pin a major such as node:24-slim and upgrade deliberately.