Fastify plugin encapsulation limits for least-privilege authentication
0 reputation · 22 Aug 2021, 14:23 UTC
0 reputation · 22 Aug 2021, 14:23 UTC
Implementing a least-privilege security model in Fastify often involves using preHandler hooks to restrict access to specific route groups. To maintain a clean architecture, authentication state is typically attached to the request object via decorators.
When these authentication decorators and hooks are defined within encapsulated plugin scopes, there is a tension between maintaining strict isolation and the need to share identity state across deeply nested plugins. Using the fastify-plugin wrapper can bypass this encapsulation, but this may inadvertently leak authentication contexts into scopes where they are not required.
Given the request lifecycle, there is uncertainty regarding the most efficient way to enforce granular permissions without compromising the isolation benefits of the plugin system.
fastify-plugin impact the ability to isolate authentication logic to specific sub-routes?onRequest hook to a nested, encapsulated plugin without making the state globally accessible?34775 reputation · 22 Aug 2021, 19:31 UTC
Wrapping your authentication plugin in fastify-plugin removes its encapsulation boundary: its hooks and decorators load into the parent scope and become visible to every sibling plugin and sub-route registered at that level. To pass verified identity into a nested plugin without making it global, declare the request decorator once in a shared ancestor scope with fastify.decorateRequest('user', null), then set request.user inside a hook registered in an encapsulated plugin on the protected subtree only. Descendants inherit the decorator and the hook; siblings see neither.
fastify-plugin changes the isolation modelFastify builds an encapsulation tree: every register() call creates a child scope, and decorators, hooks, and schemas added in a scope are inherited only by descendants. Hooks fire only for routes registered in that scope or below it. This is the mechanism that makes sub-route isolation possible.
fastify-plugin exists to skip that child scope for plugins that genuinely must be global — @fastify/jwt is the classic example. Using it for scoped authentication inverts least privilege: the enforcement surface grows from one subtree to the entire registering scope. Two consequences are easy to miss:
request.user conditionally per request does not restore isolation — the verification code still executes on sibling routes, wasting work and widening the attack surface.onRequest hook fires even for unmatched URLs. Keeping it inside an encapsulated subtree avoids that.Split the concern into two modules:
fastify-plugin-wrapped module that only calls decorateRequest('user', null) — no hooks, no verification logic. This keeps the request shape consistent across the app without leaking behavior.// shared scope: shape only, no logic
fastify.register(fp(async (app) => {
app.decorateRequest('user', null);
}));
// protected subtree: enforcement lives here
fastify.register(async (admin) => {
admin.addHook('onRequest', async (req, reply) => {
req.user = await verifyToken(req.headers.authorization);
});
admin.addHook('preHandler', async (req, reply) => {
if (!req.user?.roles.includes('admin')) {
return reply.code(403).send({ error: 'forbidden' });
}
});
admin.get('/admin/report', handler);
});
Lifecycle placement matters: onRequest runs before body parsing and validation, so it suits token or credential verification. Checks that need the parsed body, route params, or a database lookup belong in preValidation or preHandler — which is also the conventional point for authorization after authentication. For exceptions inside a protected subtree (a public route under an otherwise guarded prefix), prefer route-level preHandler options or route config over adding another fastify-plugin wrapper; per-route options adjust enforcement without reshaping the encapsulation tree.
This assumes Fastify v4/v5; v3 and older use different request APIs, and fastify-plugin majors track Fastify majors. If your hooks read route metadata, accessor names changed between majors (e.g., request.routeOptions in recent versions) — confirm against your installed versions.
request.user must be undefined there.app.printPlugins() and app.printRoutes() to confirm the auth plugin sits at the intended subtree root.request.user.One detail that could change the recommendation: if your identity verification itself depends on per-route configuration (different issuers or audiences per subtree), say so — that pushes verification into route-level options rather than a single subtree hook.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.