Safeguard
Application Security

Designing Secure Fastify Plugin Boundaries

Fastify's encapsulation model isolates plugin state by default — but one `fastify-plugin` wrapper or a stray `skip-override` symbol can silently punch a hole through every boundary you had.

Safeguard Research Team
Research
7 min read

Fastify's entire security model rests on one architectural bet: every call to register() opens a new child context, and anything a plugin adds to itself via fastify.decorate() — routes, hooks, state — is visible to that plugin's descendants but invisible to its siblings and ancestors. That produces a directed acyclic graph of scopes rather than one flat, mutable application object, which is a meaningfully different security posture than the shared global app object in an Express app. But the model has an intentional escape hatch: the fastify-plugin package lets an author deliberately break encapsulation so decorators and hooks propagate upward, and Fastify also exposes a hidden Symbol.for('skip-override') property that plugin authors can set directly to skip child-context creation entirely — a pattern Fastify's own documentation calls "not recommended" because it is easy to misuse. Get either of these wrong in a codebase with dozens of third-party plugins, and an auth preHandler registered at the wrong scope can silently apply to routes it was never meant to guard, or fail to apply to routes it was. This piece walks through how the boundary actually works, where real vulnerabilities have already come from breaking it, and how to design plugin trees that stay auditable as they grow.

How does Fastify's encapsulation actually isolate plugin state?

Fastify isolates plugin state by cloning the parent instance into a new context object every time register() runs, rather than handing the plugin a reference to the same instance everything else uses. Decorators added inside that context — via fastify.decorate(), fastify.decorateRequest(), or fastify.decorateReply() — become part of that context's prototype chain and are inherited by any plugin registered underneath it, but they do not leak sideways to a sibling plugin registered in a separate register() call, and they do not leak upward to the parent. This is documented explicitly in Fastify's Encapsulation reference as producing a "plugin tree" or DAG, where the position a plugin occupies in that tree determines exactly what state and hooks it can see. The practical security benefit is that a compromised or buggy third-party plugin — say, one pulled in transitively through an internal wrapper package — cannot reach into a sibling plugin's decorated state (a database client, a signing key, a session store) unless it was explicitly registered as a parent or the boundary was deliberately broken. That containment is the default; every deviation from it should be a conscious, reviewed decision, not an accident of import order.

When is it legitimate to break encapsulation with fastify-plugin?

Breaking encapsulation with fastify-plugin is legitimate when a plugin exists specifically to add cross-cutting functionality that every route in the application needs — a database connection pool, a logger, or an authentication decorator that route-level plugins will call by name. The package wraps a plugin function and flags it so Fastify skips creating a new child context for it, meaning its decorators and hooks attach to the parent scope instead. fastify-plugin also lets an author declare a fastify semver range and a plugin name/dependencies metadata block, which Fastify checks at boot and fails fast on if a version mismatch exists — a meaningful guardrail against silently loading a plugin that assumes an API surface your Fastify version doesn't have. The security-relevant discipline here is narrow scope: a plugin wrapped in fastify-plugin should decorate and nothing else. The moment a "shared utility" plugin also registers routes or hooks with side effects, you've made every one of those routes and hooks live at the top of the tree, visible to and potentially interfering with everything registered afterward — the opposite of the isolation the framework was designed to give you for free.

Why is the skip-override symbol considered dangerous?

The Symbol.for('skip-override') property is dangerous because it achieves the same context-skipping effect as fastify-plugin without any of its guardrails — no semver check, no declared name, no dependency validation, and often no visible marker in a code review that the plugin is doing anything unusual. A reviewer scanning a diff for fastify-plugin imports will not catch a plugin that sets this symbol directly on its exported function, because it reads like an ordinary property assignment rather than a deliberate encapsulation-breaking call. Fastify's own Plugins reference documents this usage but explicitly labels it "not recommended," which is a strong signal from the maintainers that this is a footgun path meant for framework-level tooling, not application code. In a codebase with many contributors and third-party plugins, the difference matters operationally: fastify-plugin usage is greppable and self-documenting, while a raw skip-override symbol is not. If your plugin boundary review process only greps for the package name, it will miss the symbol entirely.

What did the fastify-multipart prototype-pollution CVEs actually break?

CVE-2020-8137 and CVE-2020-8136 show what happens when a plugin's request-parsing path corrupts shared state rather than an encapsulation boundary being crossed on purpose. Versions of fastify-multipart before 1.0.5 were vulnerable to prototype pollution: a crafted multipart request could inject properties onto Object.prototype, which — because the JavaScript prototype chain is process-wide, not scoped to any one Fastify plugin context — could crash the server or corrupt behavior for every other plugin sharing that Node process, regardless of Fastify's own encapsulation. This is the sharpest reminder that Fastify's plugin-tree isolation only covers decorators and hooks that go through its own registration API; it does nothing to stop a parsing library from mutating a JavaScript-level global like Object.prototype. Fastify's dedicated Prototype Poisoning guide exists precisely because request bodies, query strings, and multipart fields are attacker-controlled input flowing through third-party parsing plugins, and a single unpatched dependency in that path can undo every scope boundary you carefully designed elsewhere in the tree.

How should teams design plugin trees to keep boundaries auditable?

Teams should design plugin trees so that the tree structure itself documents the trust boundary, rather than relying on developers to remember which plugins were wrapped in fastify-plugin. In practice that means grouping routes that share a trust level — public, authenticated, admin — under their own register() call with a distinct prefix, decorating auth and rate-limiting state only at the root of each group rather than globally, and treating any use of fastify-plugin or skip-override as something that requires the same review scrutiny as a change to a shared authentication module, because functionally that is what it is. A preHandler hook registered one level higher than intended silently applies to routes it should never touch; one registered one level lower silently exempts routes that needed it. Keeping the plugin tree shallow and grouped by trust boundary — rather than flattened for convenience with liberal fastify-plugin use — makes it possible to answer "which routes does this hook actually run on" by reading the registration tree instead of tracing runtime behavior.

How Safeguard helps

Safeguard doesn't have a Fastify-specific rule set today, but the fastify-multipart case above is exactly the class of risk its software composition analysis is built to catch: a vulnerable transitive plugin sitting in a request-parsing path, flagged with its CVE and CWE mapping before it ships. Combined with reachability analysis that checks whether your call graph actually exercises the vulnerable parsing path, and CycloneDX SBOM generation that gives you a queryable inventory of every plugin — direct and transitive — pulled into your Fastify service, Safeguard turns "which of our 40 plugins might do this" into a specific, prioritized list instead of a manual audit of package-lock.json.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.