How to Block AI Bots on Feathers.js (Node.js): Complete 2026 Guide
Feathers.js is a Node.js microservice framework for real-time APIs — REST and WebSocket (socket.io) from a single service layer. v5 (Dove) uses Koa internally; v4 (Crow) uses Express. Bot blocking has two independent layers: HTTP middleware at the transport level for hard REST blocks, and application hooks that cover both REST and WebSocket.
Two independent blocking layers
Layer A — HTTP middleware (Koa/Express): fires before Feathers routing. Hard 403, no overhead, REST only. Layer B — application hooks: fires inside Feathers on every service call. Covers REST and WebSocket. Use both for complete coverage, or hooks alone for simplicity.
Protection layers
Layer 1: robots.txt
Feathers v5 configures the Koa app directly. Serve robots.txt via koa-static registered before your bot blocker middleware so crawlers can always read your directives:
# public/robots.txt User-agent: * Allow: / User-agent: GPTBot User-agent: ClaudeBot User-agent: anthropic-ai User-agent: Google-Extended User-agent: CCBot User-agent: cohere-ai User-agent: Bytespider User-agent: Amazonbot User-agent: PerplexityBot User-agent: YouBot User-agent: Diffbot User-agent: DeepSeekBot User-agent: MistralBot User-agent: xAI-Bot User-agent: AI2Bot Disallow: /
// src/app.ts — Feathers v5 (Dove / Koa)
import { feathers } from '@feathersjs/feathers';
import { koa, rest, bodyParser, errorHandler } from '@feathersjs/koa';
import serve from 'koa-static';
import path from 'path';
const app = koa(feathers());
// ① robots.txt FIRST — served before middleware, before bot check
app.use(serve(path.join(__dirname, '..', 'public')));Layers 2, 3 & 4: Koa middleware (v5, REST)
Write the bot blocker as a Koa middleware. Register it after static files but before Feathers service routing:
// src/middleware/ai-bot-blocker.ts
import type { Middleware } from '@feathersjs/koa';
const AI_BOTS = [
'gptbot', 'chatgpt-user', 'claudebot', 'anthropic-ai',
'ccbot', 'cohere-ai', 'bytespider', 'amazonbot',
'applebot-extended', 'perplexitybot', 'youbot', 'diffbot',
'google-extended', 'deepseekbot', 'mistralbot', 'xai-bot',
'ai2bot', 'oai-searchbot', 'duckassistbot',
];
export const aiBotBlocker: Middleware = async (ctx, next) => {
const ua = ctx.request.headers['user-agent']?.toLowerCase() ?? '';
// Layer 2: set noai directive for templates
ctx.state.robots = 'noai, noimageai';
// Layer 4: hard 403 for AI bots
if (AI_BOTS.some(bot => ua.includes(bot))) {
ctx.status = 403;
ctx.body = 'Forbidden: AI crawlers are not permitted.';
return; // Do NOT call next() — stop here
}
await next(); // Inner handlers run
// Layer 3: X-Robots-Tag on every legitimate response
// Koa onion model — this runs AFTER next(), on the way out
ctx.set('X-Robots-Tag', 'noai, noimageai');
};Register in src/app.ts:
// src/app.ts
import { feathers } from '@feathersjs/feathers';
import { koa, rest, bodyParser, errorHandler, serveStatic } from '@feathersjs/koa';
import serve from 'koa-static';
import path from 'path';
import { aiBotBlocker } from './middleware/ai-bot-blocker';
const app = koa(feathers());
app.use(errorHandler());
app.use(serve(path.join(__dirname, '..', 'public'))); // ① robots.txt
app.use(aiBotBlocker); // ② bot blocker
app.use(bodyParser());
app.use(rest());
// ... configure services
export { app };Koa onion model — set X-Robots-Tag AFTER next()
In Koa, code before await next() runs on the way in. Code after await next() runs on the way out — after all inner handlers have set their response. Setting ctx.set('X-Robots-Tag', ...) after await next() means it applies to every response without inner handlers being able to override it. This is identical to the pattern in the Koa guide and Nuxt guide (both use Nitro/h3 which inherits the same model).
Feathers v4 (Crow) — Express middleware
If you're on Feathers v4, the transport is Express. Middleware syntax is standard Express (req, res, next):
// src/middleware/ai-bot-blocker.ts (Feathers v4 — Express)
import { Application } from '@feathersjs/feathers';
import type { Request, Response, NextFunction } from 'express';
const AI_BOTS = [
'gptbot', 'chatgpt-user', 'claudebot', 'anthropic-ai',
'ccbot', 'cohere-ai', 'bytespider', 'amazonbot',
'applebot-extended', 'perplexitybot', 'youbot', 'diffbot',
'google-extended', 'deepseekbot', 'mistralbot', 'xai-bot',
'ai2bot', 'oai-searchbot', 'duckassistbot',
];
export function aiBotBlocker(_app: Application) {
return (req: Request, res: Response, next: NextFunction) => {
const ua = req.headers['user-agent']?.toLowerCase() ?? '';
res.locals.robots = 'noai, noimageai'; // Layer 2: noai meta
if (AI_BOTS.some(bot => ua.includes(bot))) {
res.status(403).send('Forbidden: AI crawlers are not permitted.');
return; // Don't call next()
}
res.setHeader('X-Robots-Tag', 'noai, noimageai'); // Layer 3
next();
};
}Register in src/app.ts (Feathers v4):
// src/app.ts (Feathers v4 — Express transport)
import express from '@feathersjs/express';
import feathers from '@feathersjs/feathers';
import { aiBotBlocker } from './middleware/ai-bot-blocker';
const app = express(feathers());
app.configure(express.rest());
app.use(express.static('public')); // ① robots.txt
app.use(aiBotBlocker(app)); // ② bot blocker
app.use(express.json());
app.use(express.urlencoded({ extended: true }));Application hooks — REST + WebSocket
Feathers application hooks run for every service call regardless of transport — REST or WebSocket. Use @feathersjs/errors to throw a typed Forbidden error. This works identically in v4 and v5:
// src/hooks/block-ai-bots.ts
import { HookContext } from '../declarations';
import { Forbidden } from '@feathersjs/errors';
const AI_BOTS = [
'gptbot', 'chatgpt-user', 'claudebot', 'anthropic-ai',
'ccbot', 'cohere-ai', 'bytespider', 'amazonbot',
'applebot-extended', 'perplexitybot', 'youbot', 'diffbot',
'google-extended', 'deepseekbot', 'mistralbot', 'xai-bot',
'ai2bot', 'oai-searchbot', 'duckassistbot',
];
export const blockAiBots = async (context: HookContext): Promise<HookContext> => {
// params.headers is populated by HTTP transport (REST)
// params.connection?.headers is populated by socket.io transport
const headers =
context.params.headers ??
context.params.connection?.headers ??
{};
const ua = (headers['user-agent'] ?? '').toLowerCase();
if (AI_BOTS.some(bot => ua.includes(bot))) {
throw new Forbidden('AI crawlers are not permitted.');
// Feathers translates Forbidden → HTTP 403 for REST
// and socket.io error event for WebSocket
}
return context;
};Register as an application hook in src/app.ts:
// src/app.ts — application hooks (v4 and v5)
import { blockAiBots } from './hooks/block-ai-bots';
// After all services are configured:
app.hooks({
before: {
all: [blockAiBots], // Runs before every service method, every transport
},
});REST requests populate
context.params.headers from the HTTP request.WebSocket requests may not have
params.headers — check params.connection?.headers (the socket handshake headers) instead.Internal Feathers service calls have neither — always use optional chaining or both paths with
??.WebSocket blocking — connection hook
For socket.io, the cleanest approach is to evict AI bots at connection time — before they can subscribe to any events. The app.on('connection', ...) hook fires when a socket client connects:
// src/app.ts — WebSocket connection hook (v4 and v5)
import { blockAiBots } from './hooks/block-ai-bots';
// Evict AI bots on socket connection
app.on('connection', (connection) => {
const ua = (connection.headers?.['user-agent'] ?? '').toLowerCase();
if (AI_BOTS.some(bot => ua.includes(bot))) {
// Remove from all channels — bot receives no events
app.channel('anonymous').leave(connection);
// Optionally disconnect immediately:
// connection.socket?.disconnect(true);
return;
}
// Legitimate connections join the anonymous channel
app.channel('anonymous').join(connection);
});
// Application hooks cover any service calls bots attempt
// before the connection hook fires
app.hooks({
before: {
all: [blockAiBots],
},
});Connection hook: prevents bots from receiving any real-time events (channels).
Application hook: catches any service calls (REST or socket) that reach Feathers.
Together they cover the full attack surface. Either alone leaves a gap.
Per-service scoping
To protect only specific services (not all), use service-level hooks instead of application hooks:
// Protect only the 'articles' service
app.service('articles').hooks({
before: {
all: [blockAiBots], // Block read + write
},
});
// Protect only read operations on 'content'
app.service('content').hooks({
before: {
find: [blockAiBots], // Protect list
get: [blockAiBots], // Protect get-by-id
// create/update/remove: not blocked
},
});
// Protect all services EXCEPT 'health'
app.hooks({ before: { all: [blockAiBots] } }); // Global
// Then override for health:
// (application hooks can't be disabled per-service —
// use a guard inside the hook instead)
export const blockAiBots = async (context: HookContext) => {
if (context.path === 'health') return context; // Skip for health service
// ... rest of hook
};Feathers v5 vs v4 vs Sails.js vs Hapi.js — comparison
Feathers v5 (Dove) — Koa middleware
// Koa middleware — REST only, hard 403
const aiBotBlocker: Middleware = async (ctx, next) => {
const ua = ctx.request.headers['user-agent']?.toLowerCase() ?? '';
if (AI_BOTS.some(bot => ua.includes(bot))) {
ctx.status = 403; ctx.body = 'Forbidden'; return;
}
await next();
ctx.set('X-Robots-Tag', 'noai, noimageai');
};Feathers v4 (Crow) — Express middleware
// Express middleware — REST only, hard 403
app.use((req, res, next) => {
const ua = req.headers['user-agent']?.toLowerCase() ?? '';
if (AI_BOTS.some(bot => ua.includes(bot))) {
return res.status(403).send('Forbidden');
}
res.setHeader('X-Robots-Tag', 'noai, noimageai');
next();
});Feathers (v4/v5) — application hook (REST + WebSocket)
// Application hook — covers REST AND WebSocket
export const blockAiBots = async (context: HookContext) => {
const ua = (context.params.headers?.['user-agent'] ?? '').toLowerCase();
if (AI_BOTS.some(bot => ua.includes(bot))) {
throw new Forbidden('AI crawlers are not permitted.');
}
return context;
};
app.hooks({ before: { all: [blockAiBots] } });Sails.js — policy (Express-style)
// api/policies/isNotAiBot.js
module.exports = async function isNotAiBot(req, res, proceed) {
const ua = req.headers['user-agent']?.toLowerCase() ?? '';
if (AI_BOTS.some(bot => ua.includes(bot))) {
return res.status(403).json({ error: 'Forbidden' });
}
return proceed();
};
// config/policies.js: '*': ['isNotAiBot']Feathers v5 uses Koa (onion model, async/await). v4 uses Express (linear, next() callback). Application hooks work identically in both — the hook API is transport-agnostic. Sails.js policies use Express-style proceed() instead of next().
Testing
Use supertest for HTTP middleware and Feathers's built-in service testing for hooks:
// test/ai-bot-blocker.test.ts
import request from 'supertest';
import { app } from '../src/app';
describe('AI Bot Blocker — HTTP middleware', () => {
it('blocks GPTBot with 403', async () => {
await request(app.callback()) // app.callback() for Koa
.get('/messages')
.set('User-Agent', 'GPTBot/1.0')
.expect(403);
});
it('blocks ClaudeBot with 403', async () => {
await request(app.callback())
.get('/messages')
.set('User-Agent', 'ClaudeBot/2.0 (+https://www.anthropic.com)')
.expect(403);
});
it('allows browser with X-Robots-Tag', async () => {
const res = await request(app.callback())
.get('/messages')
.set('User-Agent', 'Mozilla/5.0 (compatible browser)')
.expect(200);
expect(res.headers['x-robots-tag']).toBe('noai, noimageai');
});
it('serves robots.txt to all UAs', async () => {
await request(app.callback())
.get('/robots.txt')
.set('User-Agent', 'GPTBot/1.0')
.expect(200);
});
});
// Test application hook directly
import { blockAiBots } from '../src/hooks/block-ai-bots';
import type { HookContext } from '../src/declarations';
import { Forbidden } from '@feathersjs/errors';
describe('blockAiBots hook', () => {
it('throws Forbidden for AI bot UA', async () => {
const ctx = {
params: { headers: { 'user-agent': 'GPTBot/1.0' } },
} as unknown as HookContext;
await expect(blockAiBots(ctx)).rejects.toBeInstanceOf(Forbidden);
});
it('returns context unchanged for browser', async () => {
const ctx = {
params: { headers: { 'user-agent': 'Mozilla/5.0' } },
} as unknown as HookContext;
const result = await blockAiBots(ctx);
expect(result).toBe(ctx);
});
it('handles missing headers gracefully', async () => {
const ctx = { params: {} } as unknown as HookContext;
const result = await blockAiBots(ctx);
expect(result).toBe(ctx); // No headers = not a bot
});
});AI bot User-Agent strings (2026)
Feathers v5 uses Koa's ctx.request.headers['user-agent']. Feathers v4 uses Express's req.headers['user-agent']. Both are lowercase strings — no normalisation needed. Application hooks use context.params.headers['user-agent'] (lowercase).
Is your site protected from AI bots?
Run a free scan to check your robots.txt, meta tags, and overall AI readiness score.