@nexavelos/security-chain
v0.1.1
Published
Spring Security-style filter chain for Express: path canonicalization, sanitization, CSRF, rate limiting and role-based authorization behind one fluent builder.
Downloads
424
Maintainers
Readme
@nexavelos/security-chain
A Spring Security-style filter chain for Express. One fluent builder for path canonicalization, payload sanitization, CSRF, rate limiting and role-based authorization — instead of eight packages wired together by hand, in an order nobody wrote down.
npm install @nexavelos/security-chain0.1.0 — early release. The API is settled enough to use and young enough to change on a minor version. Read What this does not do before you mount it in front of anything that matters.
The problem
A typical Express app assembles its security from separate packages:
app.use(helmet());
app.use(cors({ /* ... */ }));
app.use(rateLimit({ /* ... */ }));
app.use(csurf()); // deprecated, and unmaintained
app.use(mongoSanitize());
app.use(hpp());
app.use(requireAuth); // hand-rolled
app.use('/api/admin', requireRole('ADMIN')); // hand-rolled, per routeThree things go wrong, and none of them announce themselves:
- The order is load-bearing and invisible. Rate limiting after body sanitization means abusive traffic pays for a recursive object walk before anything refuses it.
- Authorization is scattered across route files. A route added later, by someone else, is protected only if they remembered.
- A forgotten
requireRoleis silent. Nothing fails, no log appears, and the endpoint is simply open.
This package fixes the order once, puts every rule in one place, and — the part that matters most — refuses a request that matched no rule.
Quick start
import express from 'express';
import { SecurityFilterChain } from '@nexavelos/security-chain';
const app = express();
app.use(express.json());
const chain = new SecurityFilterChain()
.enableStandardHeaders()
.enableCors({ origin: 'https://app.example.com', credentials: true })
.enableRateLimiting({ windowMs: 60_000, maxRequests: 100 })
.enableCsrf({ secret: process.env.CSRF_SECRET })
.enableSanitization()
.authenticateWith(async (req) => {
// Return your user, or null. Throwing means "the identity provider is
// broken" (500), not "the caller is anonymous".
return resolveUserFromToken(req.headers.authorization);
})
.authorizeHttpRequests((auth) => {
auth.requestMatchers('/api/public/**').permitAll();
auth.requestMatchers('/api/admin/**').hasAllRoles('ADMIN', 'AUDITOR');
auth.requestMatchers('/api/orders/**').authenticated();
auth.anyRequest().denyAll();
})
.build();
app.use(chain); // that is the whole integrationrequire() works identically. Both entry points are tested.
Generate a CSRF secret (32 bytes, hex):
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"What runs, and in what order
The order is fixed by the package because it is a security property, not a preference:
| # | Stage | Why here |
|---|---|---|
| 1 | Security headers (helmet) | Present even on a refused response |
| 2 | CORS | A preflight is answered before any work happens |
| 3 | Rate limiting | Before anything expensive, so abusive traffic is cheap to refuse |
| 4 | CSRF | Before the body is trusted |
| 5 | Sanitization + path canonicalization | Prototype keys, $ operators, HPP, depth limit |
| 6 | Authentication + authorization | The policy decision |
| 7 | Schema validation | Last: validating a payload the caller was never allowed to send is wasted work |
A stage that refuses a request stops the chain immediately — later stages do not run against a finished response.
Authorization
.authorizeHttpRequests((auth) => {
auth.requestMatchers('/api/public/**').permitAll();
auth.requestMatchers('/api/me').authenticated();
auth.requestMatchers('/api/reports/**').hasAnyRole('ADMIN', 'AUDITOR');
auth.requestMatchers('/api/payouts/**').hasAllRoles('ADMIN', 'FINANCE');
auth.requestMatchers('/api/legacy/**').denyAll();
auth.requestMatchers('/api/orders/:id', 'DELETE').access(
async (user, req) => user.id === await ownerOf(req.params.id)
);
auth.anyRequest().authenticated();
})| Method | Grants when |
|---|---|
| permitAll() | Always |
| denyAll() | Never |
| authenticated() | A user was resolved |
| hasAnyRole(...roles) | The user holds at least one listed role |
| hasAllRoles(...roles) | The user holds every listed role |
| hasRole(role) | Shorthand for one role |
| access(handler) | handler(user, req) resolves true |
| anyRequest() | Selects everything no other rule matched |
requestMatchers(pattern, method?) takes a Spring-style /** (or find-my-way's
*) and an optional method, so GET and POST on one path can differ.
Closed by default
🔴 Once any authorization rule exists, a request matching none of them is
refused with 403 NO_POLICY_MATCHED and the event is logged.
This is the point of the package. A mistyped pattern fails loudly on the first request instead of leaving a route quietly open for months. To opt out, say so explicitly — so that an open configuration is a sentence somebody chose to write and a reviewer can find:
new SecurityFilterChain().withDefaultPolicy('permit') // or: auth.anyRequest().permitAll()A chain with no authorization rules at all does not authorize anything and passes every request through — it is then only doing headers, CORS, rate limiting, CSRF and sanitization.
Misconfiguration fails at startup
build() throws rather than deferring a surprise to request time:
hasAllRoles()/hasAnyRole()with no roles —[].every()istrue, so an empty list would admit every authenticated user.- Two policies for one method and pattern, where registration order would silently decide the outcome.
- A pattern without a leading
/;access()without a function; a secondanyRequest().
Refusals
Every refusal carries a stable code you can branch on, and a correlation id
you can trace.
| Status | code | Cause |
|---|---|---|
| 400 | DEPTH_LIMIT_EXCEEDED | Body nested past maxDepth |
| 400 | SCHEMA_VALIDATION_FAILED | Your validator rejected the payload |
| 401 | AUTHENTICATION_REQUIRED | Policy needs a user; none resolved |
| 403 | FORBIDDEN | Signed in, insufficient permissions |
| 403 | NO_POLICY_MATCHED | No rule covered the request |
| 403 | CSRF_VALIDATION_FAILED | Token missing, mismatched, forged or expired |
| 429 | RATE_LIMITED | Over the window limit |
Configuration
enableSanitization(config?)
Removes prototype-pollution keys (__proto__, constructor, prototype),
Mongo operators ($-prefixed), dotted keys, and collapses duplicated query
parameters. Non-plain values — Date, Buffer, class instances — pass through
untouched rather than being flattened to {}.
| Option | Default | |
|---|---|---|
| blockPrototypePollution | true | |
| stripNoSqlOperators | true | Drops $-prefixed keys |
| blockDottedKeys | true | Set false if you legitimately use user.name style keys |
| maxDepth | 10 | Exceeding it returns 400 |
| hppWhitelist | [] | Parameters allowed to stay arrays |
| hppStrategy | 'first' | Or 'last' |
The canonical path is exposed as req.securityPath. The request itself is
never modified — see Design notes.
enableCsrf(config?)
Double-submit cookie with an HMAC-signed, expiring token. The cookie is parsed
from the Cookie header directly, so cookie-parser is not required.
| Option | Default | |
|---|---|---|
| secret | process.env.CSRF_SECRET | Required, minimum 32 chars |
| cookieName / headerName / bodyField | csrf_token / X-CSRF-Token / _csrf | |
| httpOnly | false | The client must read the token to send it back |
| secure | true in production | |
| sameSite | 'lax' | |
| maxAgeMs | 24 h | |
| ignoreMethods | GET, HEAD, OPTIONS | These mint a token |
| excludePaths | — | RegExp; use for inbound webhooks |
| exemptBearerAuth | true | See below |
How the flow works. A safe method sets the cookie and returns the token on
the X-CSRF-Token response header. The client sends it back on that header (or
in the _csrf body field). When CORS is enabled, the chain adds the header to
Access-Control-Expose-Headers automatically — without that a cross-origin SPA
cannot read it.
⚠️ exemptBearerAuth skips CSRF for requests carrying Authorization: Bearer.
A browser cannot attach that header cross-site without a preflight the
attacker's origin fails, so such a request is not the ambient-credential case
CSRF defends against. If your app accepts both cookie and bearer credentials
on the same route, set this to false — otherwise the caller chooses which
check applies.
🔴 The chain refuses to start without a secret. Unsigned tokens would make the signature check meaningless, which is worse than an error at boot.
enableRateLimiting(config)
| Option | Default | |
|---|---|---|
| windowMs, maxRequests | — | Required |
| store | MemoryStoreAdapter | |
| keyGenerator | req.ip | |
| skip | — | e.g. health checks |
| message | a string | |
⚠️ req.ip is only trustworthy if Express trust proxy matches your actual
deployment. Unset behind a proxy, every caller shares one address and a single
client exhausts everyone's quota. Set too permissively, X-Forwarded-For is
attacker-controlled and the limit is bypassed with a header. This package cannot
detect either case for you.
Distributed limiting uses Redis with a sliding window and a real circuit breaker — consecutive-failure threshold, cooldown, half-open probe. A timeout counts as a failure, so a slow-but-connected Redis does not charge every request the full timeout:
import Redis from 'ioredis';
import { ResilientRedisStoreAdapter } from '@nexavelos/security-chain';
.enableRateLimiting({
windowMs: 60_000,
maxRequests: 100,
store: new ResilientRedisStoreAdapter({
redisClient: new Redis(process.env.REDIS_URL),
fallbackStrategy: 'fail-open', // or 'fail-closed'
timeoutMs: 200,
}),
})fail-open degrades to a per-instance memory counter — a weaker limit, but the
site stays up. fail-closed refuses everything while Redis is unreachable.
Choose deliberately.
ioredis is an optional peer dependency; the memory store needs nothing.
observeWith(config?)
Every refusal emits a structured SecurityEvent.
.observeWith({
onEvent: (event) => siem.write(event),
disableConsole: true,
redactIp: true, // IP is personal data under GDPR and the DPDP Act
redactRoles: true,
})A correlation id is read from x-correlation-id or generated, echoed on the
response, and attached to every event. A sink that throws is contained — it
never turns into a failed request.
enableSchemaValidation(config)
Bring your own validator — zod, ajv, TypeBox, anything:
.enableSchemaValidation({
targets: ['body'],
validator: (data) => {
const parsed = UserSchema.safeParse(data);
return parsed.success
? { success: true, data: parsed.data }
: { success: false, errors: parsed.error.issues.map((i) => ({
path: i.path.join('.'), message: i.message,
})) };
},
})enableStandardHeaders(config?) / enableCors(config)
Thin wrappers over helmet and cors with defaults chosen for an API. Anything
those packages accept is passed through.
What it costs
Measured, not estimated — npm run bench compares a bare Express route against
the same route behind the full chain in one run:
| | Bare Express | Full chain | |---|---|---| | Service time | 0.139–0.170 ms | 0.214–0.216 ms | | Throughput | 5,872–7,209 req/s | 4,636–4,667 req/s |
The chain costs roughly 45–80 microseconds per request. Two runs on the same
laptop gave 44 µs and 77 µs; the bare-Express baseline itself moved 23% between
them, so treat this as an order of magnitude rather than a precise figure, and
re-run npm run bench on hardware you care about.
⚠️ Mean latency is the wrong number to quote. Under saturation it is dominated by queueing — with N connections in flight, latency is roughly N / throughput — so it grows with load and says little about this package. The same runs showed latency rising 13–17 ms to 21 ms at 100 connections, which is the queue, not the work. Service time is the figure that holds.
Measured on loopback with no TLS, no datastore and no real identity provider, on Node 24. Real deployments pay for all three, and those costs dwarf this one.
What this does not do
Naming the limits honestly, because a security package that implies more than it delivers is worse than one that does less:
- ❌ It is not a WAF. No SQLi or XSS payload detection, no bot mitigation, no IP reputation.
- ❌ It does not verify JWTs or manage sessions. You supply
authenticateWith; the package never parses a token. - ❌ It does not encode output. XSS is prevented where you render, not here.
- ❌ It does not enforce a body size limit. Use
express.json({ limit }). - ❌ It does not authorize at the data layer. A route-level rule cannot stop a query that forgets its tenant filter.
- ❌ It is Express only. No Fastify, Koa or NestJS adapter.
- ❌ Role checks are exact string matches. No hierarchy, no wildcards.
- ⚠️ Sanitization drops keys rather than rejecting the request. The handler sees a clean object; if you need the caller told, validate with a schema.
- ⚠️ CSRF is double-submit, not synchronizer. Signed and expiring, but with no server-side state — an attacker who can set cookies on your domain (a subdomain XSS, for instance) defeats any double-submit scheme.
Design notes
The request is never mutated. The chain does not rewrite req.url, and the
canonical path is offered separately as req.securityPath. Rewriting the URL
changes what every downstream router, logger and proxy sees, and it is how a
security layer becomes a new attack surface — an earlier version lowercased
every path and silently corrupted case-sensitive identifiers.
The practical consequence: policy is matched on the canonical path, while
Express routes on the path the client sent. A traversal like
/api/public/%2e%2e/admin/x is judged by the /api/admin/** rule — it cannot
borrow the permissive one — and Express then returns 404 because it does not
resolve dot-segments. Refusal comes from the policy layer; 404 comes from your
router.
Matching is case-insensitive so /API/ADMIN cannot slip past a rule written
for /api/admin, but the request the application receives is byte-for-byte what
the client sent.
Requirements
| | |
|---|---|
| Node | ≥ 20 |
| Express | ^4.18 or ^5 — both verified against 4.22.2 and 5.2.1 |
| TypeScript | Types included; not required |
Contributing and security
Bugs and ideas: GitHub issues. Vulnerabilities: read SECURITY.md first — please do not open a public issue.
Licence
MIT © 2026 Febin K Augustine (NexaVelos Digital)
