FeathersJS Service Hooks as a Single Cross-Cutting Boundary for REST and WebSocket APIs
Architecture note on using FeathersJS service hooks to enforce authentication, authorization, validation, and pagination across REST and WebSocket transports with explicit trust and data boundaries.
22 Feb 2026, 21:43 UTC

The Problem: One Policy, Two Transports
In a small FeathersJS API that serves both REST and WebSocket clients, authentication and validation rules tend to drift when they live in separate middleware stacks. A rule added to an Express route protects REST callers but leaves socket callers unprotected, and an app.service() call from elsewhere in the application can bypass transport middleware entirely. The useful takeaway is that the smallest reliable design puts cross-cutting policy in service hooks so REST, WebSocket, and internal callers share one enforcement path.
These notes assume Feathers 5 with @feathersjs/authentication and a database adapter such as @feathersjs/mongoose or @feathersjs/sequelize. Hook signatures, TypeScript types, and authentication options have changed across major versions, so confirm details against your installed release.
Requirements for a Small CRUD + Realtime Service
For a CRUD plus realtime service, the smallest suitable design is one Feathers application with explicit services and a hook pipeline. Model only the nouns that need client access, such as users and messages. Use built-in service methods and hook order rather than custom middleware that duplicates service logic. Keep hooks small, named, and testable; compose them per service instead of creating a generic hook that branches on many methods.
Smallest Suitable Design: Thin Services, Hook Pipelines
A Feathers service exposes standard methods: find, get, create, patch, update, and remove. Hooks run before and after these methods and on errors. The pipeline is the natural place for policy because every transport funnels through it.
The following hook file illustrates a representative pipeline for a messages service. It authenticates all calls, validates the query on find, strips client-supplied ownership on create, and normalizes pagination in an after hook.
// src/services/messages/messages.hooks.js
const { authenticate } = require('@feathersjs/authentication').hooks;
const { resolve, validateSchema } = require('@feathersjs/schema');
const messageSchemas = require('./message.schema');
module.exports = {
before: {
all: [authenticate('jwt')],
find: [resolve(messageSchemas.queries.find)],
create: [
validateSchema(messageSchemas.create),
async (context) => {
const { user } = context.params;
// Derive ownership from the authenticated principal.
context.data.ownerId = user.id;
// Strip fields the client must not control.
delete context.data.role;
delete context.data.isAdmin;
return context;
}
],
patch: [],
remove: []
},
after: {
all: [],
find: [
async (context) => {
if (Array.isArray(context.result)) {
context.result = {
total: context.result.length,
limit: context.params.query.$limit || 25,
skip: context.params.query.$skip || 0,
data: context.result
};
}
return context;
}
]
},
error: {
all: [
async (context) => {
// Replace adapter-specific errors with generic Feathers errors
// before they reach the client.
if (context.error.name === 'MongoServerError') {
context.error = new errors.GeneralError('Service unavailable');
}
return context;
}
]
}
};
This example is a configuration pattern, not a tested output. The exact schema resolver names and error classes depend on your installed packages.
Trust Boundary: Treat Every Transport as Untrusted
Treat all transport input as untrusted, including REST params, query strings, request bodies, socket data, and provider metadata. Authentication should establish identity once, ideally in an authentication hook or service. Authorization should be evaluated per service and method from context.params.user, never from client-supplied identity fields.
Provider metadata is the key signal for distinguishing callers. context.params.provider is 'rest' for HTTP calls, 'socket' for WebSocket calls, and undefined for internal app.service() calls. REST-only middleware does not protect internal calls, so security logic must live in hooks rather than in Express or socket middleware.
Data Boundary: DTOs, Internal Models, and Server-Owned Fields
Separate external DTOs (Data Transfer Objects) from internal models. Validate and sanitize context.data before create, patch, and update. Strip client attempts to set internal or ownership fields, and derive tenant or owner values from the authenticated principal. Apply the same rules to internal server calls unless they are deliberately trusted.
A common mistake is trusting context.data.ownerId because the client sent it. The hook above overwrites it with context.params.user.id, which closes that gap for both REST and socket callers.
Pagination and Query Safety
Feathers database adapters commonly expose query operators such as $limit, $skip, $sort, and $select, but clients can abuse them. Enforce schema validation, allowlists, maximum page size, and sortable-field restrictions server-side. Do not assume large offsets or unindexed sorts are cheap; a $skip of 100000 can force the database to scan and discard rows.
Pagination defaults vary by adapter and by the paginate option. If a service returns an array when paginate is disabled and a paginated object when it is enabled, clients can break when that setting changes. Normalize the shape in an after hook if your clients expect a stable contract.
Operational Checks and Verification
Log structured service, method, provider, user, duration, and error data without leaking payloads or personally identifiable information. Add health and readiness checks outside the Feathers service surface so infrastructure probes do not authenticate or trigger hooks.
Unit-test hook order and run at least one integration test per transport because provider metadata and middleware behavior differ between REST and sockets.
Diagnostic Commands
1. Log provider metadata. Add a temporary hook that logs context.path, context.method, context.params.provider, and context.params.user. Run it inside your application runtime. Expected: REST shows rest, socket shows socket, internal app.service() shows undefined. Risk: logging context.data may leak PII; log identifiers only.
2. Check field stripping via REST. Run in a terminal with permission to reach the API:
curl -X POST http://localhost:3030/messages \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-d '{"text":"hello","ownerId":"attacker-id","role":"admin"}'
Expected: the response contains ownerId matching the authenticated user, not attacker-id, and no role field. Risk: if the before hook order is wrong, the client may set ownership.
3. Internal call test. Run inside your application context:
await app.service('messages').create({ text: 'internal' });
Expected: the same before hooks run and context.params.provider is undefined. Risk: REST-only middleware is bypassed; security must live in hooks.
Failure Modes to Plan For
- Throwing in an after hook. An after hook that throws can turn a successful write into a failed response unless the error is handled. If an after hook only shapes data, catch and log instead of throwing.
- Async hooks not awaited. Hooks that return a promise must be awaited or returned; a floating promise can cause the service method to run before validation completes.
- Socket connections outlive token expiry. A WebSocket connection can remain open after the JSON Web Token expires. Plan re-authentication and event authorization rather than assuming the initial handshake is sufficient.
- Pagination shape changes. Switching a service from an array response to a paginated object breaks clients. Version the change or normalize the shape in an after hook.
When to Change the Design
The single-app, single-hook-pipeline design fits small CRUD plus realtime APIs. Change it when these conditions appear:
- Multiple apps need the same policy. Extract hooks into shared packages so authentication, validation, and pagination rules are not copied.
- Filtering becomes relational or expensive. Add a query layer or database views if clients need joins or aggregations that adapters cannot express efficiently.
- Realtime fan-out grows. Move events to a message bus if a single process cannot handle the publication load.
- Compliance or tenant isolation demands it. Split read/write services or databases when a single service surface cannot satisfy audit or isolation requirements.
Before any upgrade, inspect the package lockfile for the installed Feathers and adapter versions, then run unit tests plus one REST and one WebSocket integration test to confirm hook signatures and provider behavior have not shifted.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.