Designing a Secure FeathersJS Service with Role‑Based Access and Real‑Time Events
A concise architecture note showing how a single FeathersJS service with JWT before‑hooks and whitelist‑based after‑hooks satisfies CRUD, role‑based access, and emits only whitelisted fields over Socket.io, with failure handling and scaling tips.
07 May 2026, 01:22 UTC

Problem and Takeaway
You need a backend that exposes CRUD over REST and WebSocket, enforces role‑based access, and broadcasts only safe data to connected clients. The takeaway is that a single FeathersJS service, equipped with a before‑hook for JWT authentication/authorization and an after‑hook that whitelists fields before emitting Socket.io events, satisfies these requirements without extra middleware.
Requirements
- Expose create, read, update, delete via REST and WebSocket.
- Validate incoming requests with a JWT and map roles to permissions.
- After a successful mutation, send a real‑time event containing only a predefined set of public fields.
- Handle errors gracefully: authentication failures → 401/403, DB errors → 500, event‑emit failures logged but do not abort the request.
Smallest Suitable Design
A single service backed by a database adapter (e.g., Sequelize) is sufficient. The service registers two hooks:
- before hook – runs authentication, role checks, and input sanitisation.
- after hook – maps the raw model instance to a DTO (whitelist) and emits a Socket.io event.
No custom transport or extra middleware is required; FeathersJS already routes the same service logic to REST and WebSocket.
Example Service Setup
# In project root (requires Node >=14, npm, write permission to src/services)
npx feathers generate service
# Choose: name = messages, DB = Sequelize, ORM = none (we’ll add hooks manually)
After generation, edit src/services/messages/messages.hooks.js:
const { authenticate } = require('@feathersjs/authentication').hooks;
const { disallow } = require('feathers-hooks-common');
// Whitelist of fields safe to broadcast
const PUBLIC_FIELDS = ['id', 'text', 'userId', 'createdAt'];
function sanitizeData(context) {
if (context.method === 'create' || context.method === 'patch') {
// Keep only allowed fields; extra fields are dropped
context.data = Object.keys(context.data)
.filter(key => PUBLIC_FIELDS.includes(key))
.reduce((obj, key) => ({
...obj,
[key]: context.data[key]
}), {});
}
return context;
}
function emitEvent(context) {
const app = context.app;
const payload = PUBLIC_FIELDS.reduce((obj, key) => ({
...obj,
[key]: context.result[key]
}), {});
// Emit to all connected Socket.io clients
app.service('messages').emit('created', payload);
return context;
}
module.exports = {
before: {
all: [authenticate('jwt')],
find: [],
get: [],
create: [sanitizeData, (context) => {
// Example role check: only 'admin' or 'user' can create
if (!['admin', 'user'].includes(context.params.user.role)) {
throw new Error('Insufficient permissions');
}
return context;
}],
update: [disallow()],
patch: [sanitizeData, (context) => {
if (context.params.user.role !== 'admin') {
throw new Error('Only admins may update');
}
return context;
}],
remove: [disallow()]
},
after: {
all: [],
find: [],
get: [],
create: [emitEvent],
update: [],
patch: [emitEvent],
remove: []
},
error: {
all: [],
find: [],
get: [],
create: [],
update: [],
patch: [],
remove: []
}
};
Trust and Data Boundaries
- The service layer trusts only the
context.params.userobject populated by the JWT authentication hook. No other client‑supplied data is trusted. - Input sanitisation (
sanitizeData) strips any fields not inPUBLIC_FIELDSbefore they reach the Sequelize model, preventing over‑posting. - Outgoing events are built from the whitelist (
PUBLIC_FIELDS) after the database operation succeeds, ensuring no secret columns (e.g., hashed password, internal flags) leak.
Operational Checks and Failure Modes
Checks
- Authentication – Send a request without a token: expect HTTP 401. With a valid JWT for a permitted role: expect 200/201 and the created record.
- Authorization – Use a JWT with role \"guest\" (not in allowed list) on a create request: expect 403.
- Database error – Simulate a DB failure (e.g., rename the table) and trigger a create: expect 500, no event emitted, and the error logged.
- Event emission failure – Temporarily stop the Socket.io server (\"pkill -f socket.io\") while the Feathers process runs: the request still returns 201, but the client does not receive the event; the server logs a warning about emit failure.
- Reconnection – Restart the Socket.io server; clients automatically reconnect and resume receiving new events. Events emitted during the downtime are lost unless a persistence layer (Redis adapter) is added.
Failure Modes
- Hook errors (auth/role) abort the flow before any DB write, returning 401/403.
- DB errors trigger a 500; Feathers does not retry automatically – you may wrap the service in a retry middleware if needed.
- Event emit failures are caught by Feathers’ error handler; they are logged but do not change the HTTP response status.
When the Design Would Change
- If exactly‑once delivery of events is required (e.g., financial transactions), replace the direct Socket.io emit with a durable message broker (RabbitMQ, Kafka) and have consumers broadcast to clients.
- If the public field set varies per role or context, move the whitelist logic into a reusable service method or a dynamic DTO factory rather than a static array.
- If you need to enforce complex, cross‑service validation (e.g., unique constraints across multiple models), consider adding a dedicated service or a database‑level constraint and keep hooks focused on auth and sanitisation.
- When the application scales beyond a single Node process, add a Redis adapter for Socket.io (
npm i @socket.io/redis-adapter) so events are broadcast across instances.
Practical Verification Steps
- Start the Feathers app:
npm start(ensurePORTandJWT_SECRETare set). - Obtain a JWT:
curl -X POST http://localhost:3030/authentication -H 'Content-Type: application/json' -d '{\"strategy\":\"local\",\"email\":\"[contact removed]\",\"password\":\"secret\"}'(adjust credentials). - Create a message with the token:
curl -X POST http://localhost:3030/messages -H 'Authorization: Bearer <JWT>' -H 'Content-Type: application/json' -d '{\"text\":\"Hello\",\"userId\":\"1\"}'. Verify response status 201 and that the JSON contains onlyid,text,userId,createdAt. - Open a Socket.io client (e.g.,
npm i socket.io-clientand run a small script) and confirm it receives acreatedevent with the same payload. - Repeat steps 2‑4 with a token for a disallowed role; expect 403 and no DB entry.
- Stop the Socket.io server (
pkill -f socket.io), create another message, and observe that the HTTP call succeeds but the client does not receive the event. Restart the server and verify that new messages resume event delivery.
These steps give confidence that the service enforces the intended trust boundaries, handles errors as designed, and only changes behavior when the listed conditions (exactly‑once delivery, per‑role whitelists, multi‑instance scaling) arise.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.