On 28 August, Backstage shipped release 1.54.6 with 37 security advisories in one day. That's more than the 31 the project had published from its first advisory in June 2021 to July 2026. Six more followed in September. GitHub's advisory database and OSV added most of them on 7 October, 40 days later, so npm audit and most scanners had nothing to report for almost six weeks. As of 17 October, four of them still aren't there at all.
If you run Backstage, the release notes are the place to start, but it's a long list. This guide is for platform teams that run a Backstage instance. It covers what the 43 advisories touch, the timeline from fix to scanner, three patterns worth understanding beyond the version bump, and a script that checks a lockfile against Backstage's own advisory feed, including the four your scanner can't see.
The short answer
Between 28 August and 16 September 2026, Backstage published 43 security advisories: 1 critical, 16 high, 23 medium and 3 low, mostly in the Scaffolder (13), TechDocs (12) and the catalog (6). GitHub's database and OSV added 38 of them on 7 October and one on 15 October. Four, including two high-severity TechDocs issues (CVE-2026-88064 and CVE-2026-106556), weren't in either database on 17 October, so npm audit doesn't report them. Upgrade to at least @backstage/backend-defaults 0.17.8, plugin-scaffolder-backend 4.1.0, plugin-techdocs-node 1.15.6 (or 1.14.8 or 2.0.1) and plugin-catalog-backend 3.9.1, and check your lockfile against the project's own advisory feed rather than the database alone.
What the 43 advisories touch
| Area | Advisories | Highest | What they cover | Fixed in (minimum) |
|---|---|---|---|---|
| Scaffolder | 13 | Critical | Task data and logs exposing secrets, task authorization, SCM credential fallback, input and path checks in Bitbucket, Confluence and Sentry actions | plugin-scaffolder-backend 4.1.0, plus module releases |
| TechDocs | 12 | High | mkdocs.yml validation (8 high), file reads through snippets and the local publisher, static content authorization | plugin-techdocs-node 1.15.6, plugin-techdocs-backend 2.2.4 |
| Catalog | 6 | High | Placeholder URL validation, property permissions, location types, GitLab, Bitbucket Server and cloud storage ingestion | plugin-catalog-backend 3.9.1, plus module releases |
| Backend services | 3 | High | Service credential restrictions lost through delegation, excluded actions still callable, cloud storage URL readers | backend-defaults 0.17.8 |
| Auth | 3 | High | Unverified OIDC email used for sign-in, negative email verification ignored, Cloudflare Access audience | OIDC provider 0.4.20, plugin-auth-node 0.6.15 or 0.7.5, Cloudflare Access provider 0.5.0 |
| Kubernetes | 3 | Medium | Sensitive data in resource queries, entity validation, cluster auth modes | plugin-kubernetes-backend 0.21.10 |
| Proxy | 2 | Medium | Input validation, overlapping routes with different credentials | plugin-proxy-backend 0.6.18 |
| Search | 1 | Medium | Permission filtering in search engines | plugin-search-backend 2.1.6, Elasticsearch module 1.8.7 |
All but one require some privilege in their CVSS vectors, usually a signed-in user, and many depend on configuration, so the severity on the label is a ceiling for your instance, not a measure of it. Most advisories also list workarounds, which matter if you can't upgrade every plugin at once.
How long scanners waited
| Date | Event |
|---|---|
| 28 August | Backstage 1.54.6 released; 37 repository advisories published with fixed npm versions |
| 11 and 16 September | Six more advisories (auth, proxy, TechDocs, and the Sentry and Bitbucket Server modules) |
| 16 September | First CVE record of the batch published (CVE-2026-88064) |
| 6 and 7 October | CVE records for nearly all the rest published (CVE-2026-106495 followed on 12 October) |
| 7 October | GitHub's database and OSV add 38 advisories |
| 15 October | GitHub's database and OSV add CVE-2026-106495 |
| 17 October | Four advisories still in neither database |
So most fixes were on npm from day one, and the advisories were public on GitHub, but a team relying on npm audit or a database-fed scanner heard nothing for 40 days. The four still missing are GHSA-292q-8mf5-h6g3 (CVE-2026-88064, high), GHSA-g2hj-v2j6-vfvg (CVE-2026-106556, high), GHSA-vmqh-7cj9-mvcj (CVE-2026-106507, medium) and GHSA-8429-xpcj-3cg5 (CVE-2026-106497, medium). Their CVE records exist, and OSV carries them as CVE entries with Git commit ranges. But npm scanners match by package name and version, and those entries have neither, so they don't fire.
We don't know why these four didn't come through with the rest. The practical point is the same either way: for a project that publishes advisories on GitHub, the project's own feed is ahead of the database, sometimes by weeks.
Three patterns worth knowing
TechDocs builds untrusted config on your server. Eight of the TechDocs advisories are high-severity ways around the validation of a repository's mkdocs.yml, through markdown_extensions, themes, plugins and the sanitizer itself. One (CVE-2026-106510) gives OS command execution on the build host to any user who can register a catalog entity. The fixes landed in stages, with the last on 25 September in plugin-techdocs-node 1.14.8, 1.15.6 and 2.0.1. The release notes also ask TechDocs users to move to pymdown-extensions 10.21.3 or newer, normally through mkdocs-techdocs-core 1.7.0 or newer. The structural fix is older: Backstage's documentation already recommends building docs in CI and setting techdocs.builder: 'external', so the Backstage server only serves finished HTML. Then a hostile mkdocs.yml runs in a CI job for its own repository, not on the portal.
Scaffolder tasks hold secrets, and other users could read them. The one critical advisory, CVE-2026-106501, lets a user who can read another user's Scaffolder task receive internal execution data, which in some setups includes credentials for an external service. Three more cover secrets in task logs and failure events. The workaround is worth keeping after the upgrade: give the scaffolder.task.read permission the isTaskOwner condition, so people only see their own tasks.
A filter that hides an action isn't one that blocks it. Backstage's actions registry lets plugins expose actions that other services, such as Backstage's MCP actions backend, can list and call. Operators can narrow that set with backend.actions.pluginSources and include or exclude rules in backend.actions.filter. Before backend-defaults 0.17.8, those settings were applied only when listing actions. The invoke endpoint didn't check them, so an authenticated user who knew an excluded action's ID could still call it (CVE-2026-106495). Action permissions and downstream authorization still applied, which is why it's rated medium. The fix runs the same check on both paths:
// DefaultActionsRegistryService, after the fix (commit a8fb34a)
if (!this.isActionAllowed([req.params.actionId, action])) {
throw new NotFoundError(`Action "${req.params.actionId}" not found`);
}
The same pattern turns up wherever a tool list for an AI agent is built from configuration. Trimming the list the agent sees is useful, but the place that runs the tool has to enforce the same rule.
Check your lockfile against Backstage's own feed
This script reads package-lock.json or yarn.lock (classic or Berry), fetches the published advisories from Backstage's repository through the GitHub API, and lists every advisory that matches an installed @backstage/* version. With --osv it also asks OSV which advisories it returns for each version, and marks the ones it doesn't. It uses the Python standard library. Unauthenticated GitHub API calls are limited to 60 an hour, so set GITHUB_TOKEN in CI or save the feed and pass --advisories:
#!/usr/bin/env python3
"""Match @backstage/* packages in a lockfile against Backstage's own advisory feed.
Reads package-lock.json or yarn.lock, fetches the published repository
advisories for backstage/backstage from the GitHub API (or a saved copy),
and with --osv reports which matching advisories OSV's npm data does not
return for that package version. Standard library only.
"""
import argparse, json, os, re, sys, urllib.request
FEED = ("https://api.github.com/repos/backstage/backstage/"
"security-advisories?state=published&per_page=100")
UA = {"User-Agent": "backstage-advisory-check", "Accept": "application/vnd.github+json"}
def get(url, data=None):
headers = dict(UA)
if os.environ.get("GITHUB_TOKEN") and "github.com" in url:
headers["Authorization"] = "Bearer " + os.environ["GITHUB_TOKEN"]
req = urllib.request.Request(url, data=data, headers=headers)
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r), r.headers.get("Link", "")
def fetch_feed():
out, url = [], FEED
while url:
page, link = get(url)
out += page
m = re.search(r'<([^>]+)>;\s*rel="next"', link)
url = m.group(1) if m else None
return out
def parse_lock(path):
found = set()
text = open(path, encoding="utf-8").read()
if path.endswith(".json"):
for key, meta in json.loads(text).get("packages", {}).items():
name = key.split("node_modules/")[-1]
if name.startswith("@backstage/") and "version" in meta:
found.add((name, meta["version"]))
return found
# yarn.lock, classic or berry: a header line, then an indented version line
name = None
for line in text.splitlines():
if line and not line.startswith(" "):
m = re.match(r'"?(@backstage/[^@"\s]+)@', line)
name = m.group(1) if m else None
elif name:
m = re.match(r'\s+version:?\s+"?([^"\s]+)"?', line)
if m:
found.add((name, m.group(1)))
name = None
return found
def vkey(v):
core, _, pre = v.partition("-")
nums = [int(x) for x in core.split(".")[:3]]
# a prerelease sorts before its release
return nums + ([0, pre] if pre else [1, ""])
def satisfies(version, rng):
ops = {"<": lambda a, b: a < b, "<=": lambda a, b: a <= b,
">": lambda a, b: a > b, ">=": lambda a, b: a >= b,
"=": lambda a, b: a == b}
v = vkey(version)
for alt in rng.split("||"):
# conditions may be comma or space separated; "=<" appears in old entries
conds = re.findall(r"(<=|>=|=<|=>|<|>|=)?\s*(\d[\w.-]*)", alt)
if conds and all(ops[{"=<": "<=", "=>": ">="}.get(op, op or "=")](v, vkey(ver))
for op, ver in conds):
return True
return False
def osv_ids(pkgs):
body = {"queries": [{"package": {"ecosystem": "npm", "name": n}, "version": v}
for n, v in pkgs]}
res, _ = get("https://api.osv.dev/v1/querybatch", json.dumps(body).encode())
return {p: {x["id"] for x in r.get("vulns", [])} for p, r in zip(pkgs, res["results"])}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("lockfile")
ap.add_argument("--advisories", help="saved feed JSON instead of the live API")
ap.add_argument("--osv", action="store_true", help="flag advisories OSV does not return")
a = ap.parse_args()
feed = json.load(open(a.advisories)) if a.advisories else fetch_feed()
pkgs = sorted(parse_lock(a.lockfile))
seen = osv_ids(pkgs) if a.osv and pkgs else {}
hits = []
for adv in feed:
ids = {adv["ghsa_id"], adv.get("cve_id")}
for vuln in adv.get("vulnerabilities") or []:
for name, ver in pkgs:
fixed = vuln.get("patched_versions") or ""
if name != vuln["package"]["name"] or not satisfies(ver, vuln["vulnerable_version_range"]):
continue
# a few repo advisories have open-ended ranges; trust an explicit fixed range
if re.search(r"[<>=]", fixed) and satisfies(ver, fixed):
continue
gap = a.osv and not (ids & seen.get((name, ver), set()))
hits.append((adv["severity"], adv["ghsa_id"], adv.get("cve_id") or "-",
f"{name}@{ver}", fixed or "none",
" NOT IN OSV" if gap else "", adv["summary"]))
order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
print(f"{len(pkgs)} @backstage packages, {len(feed)} advisories in feed")
for h in sorted(set(hits), key=lambda h: (order.get(h[0], 9), h[3], h[1])):
print(f"{h[0].upper():8} {h[1]} {h[2]:16} {h[3]} fix {h[4]}{h[5]}\n {h[6]}")
gaps = sum(1 for h in set(hits) if h[5])
print(f"{len(set(hits))} matches" + (f", {gaps} not returned by OSV for that version" if a.osv else ""))
return 1 if hits else 0
if __name__ == "__main__":
sys.exit(main())
We tested it on two lockfiles generated with npm install --package-lock-only --ignore-scripts. The first pins the versions a portal might have run in August: backend-defaults 0.17.7, plugin-techdocs-node 1.15.3, plugin-scaffolder-backend 4.0.4, plugin-catalog-backend 3.9.0 and the OIDC provider 0.4.19. The second uses current versions. We saved the feed on 17 October and filtered the first run to the critical finding and the database gaps:
$ python3 backstage_advisory_check.py aug-portal/package-lock.json \
--advisories feed-2026-10-17.json --osv | grep -E -A1 "^CRITICAL|NOT IN OSV|matches"
CRITICAL GHSA-g2v8-7jhw-pp8p CVE-2026-106501 @backstage/plugin-scaffolder-backend@4.0.4 fix 4.1.0
Sensitive information exposure in Scaffolder
--
HIGH GHSA-292q-8mf5-h6g3 CVE-2026-88064 @backstage/plugin-techdocs-node@1.15.3 fix 1.15.4 NOT IN OSV
Improper input validation in TechDocs MkDocs configuration
--
HIGH GHSA-g2hj-v2j6-vfvg CVE-2026-106556 @backstage/plugin-techdocs-node@1.15.3 fix 1.15.4 NOT IN OSV
Configuration bypass in TechDocs mkdocs.yml sanitization
--
MEDIUM GHSA-8429-xpcj-3cg5 CVE-2026-106497 @backstage/plugin-catalog-backend@3.9.0 fix 3.9.1 NOT IN OSV
Inconsistent catalog property permission evaluation
--
MEDIUM GHSA-vmqh-7cj9-mvcj CVE-2026-106507 @backstage/plugin-techdocs-node@1.15.3 fix 1.15.4 NOT IN OSV
TechDocs arbitrary file read via mkdocs snippets
--
27 matches, 4 not returned by OSV for that version
$ python3 backstage_advisory_check.py oct-portal/package-lock.json \
--advisories feed-2026-10-17.json --osv
32 @backstage packages, 74 advisories in feed
0 matches, 0 not returned by OSV for that version
On the same August lockfile, npm audit reported 23 of the 27 Backstage advisories, missing the same four. The script exits with status 1 when anything matches, so it can gate a CI job. One caution: a few older repository advisories have loose version ranges, so the script trusts an explicit fixed range where the advisory gives one.
What to do
- Upgrade the backend packages to at least the versions in the short answer, plus any Scaffolder, catalog or auth modules you use. The table above lists the minimums, and the script will flag the modules.
- Move TechDocs builds to CI with
techdocs.builder: 'external'if you haven't, and update the build image tomkdocs-techdocs-core1.7.0 or newer. - Limit who can register catalog entities, since several advisories start there.
- Add
isTaskOwnertoscaffolder.task.readunless people genuinely need to see each other's tasks. - Review action filters and
pluginSourcesif you expose actions to MCP clients, and upgradebackend-defaultsso the filters actually block. - Watch the project's advisory feed, not only
npm audit. Our post on what npm audit misses covers the wider gap, and how GitHub's advisory database is curated explains where the delay comes from.
The fair view
A release like this is what good security work looks like from the outside. Backstage fixed 37 issues at once, with backports to older plugin release lines, wrote an advisory with workarounds for nearly every one, and kept fixing as follow-up problems turned up in September. Releasing everything together also gave operators one upgrade instead of a drip of 37. None of the advisories credit an outside reporter, so this reads as a deliberate review of the codebase, though the advisories don't say how the issues were found.
The gap is downstream. A platform that holds SCM tokens, cloud credentials and deployment permissions for a whole engineering organization waited 40 days for its scanners to notice, and four advisories still aren't visible to them. That's not a reason to distrust the database, which got 39 of 43 right. It's a reason to read the source when the software matters: one API call to Backstage's advisory feed would have told you on 28 August.