@spfn/core/middleware
Production Hono middleware factories and a masking helper: ErrorHandler (serializes
thrown errors into HTTP responses), RequestLogger (structured request/response logging
with request IDs, slow-request detection, and sensitive-data masking), createProxyGuard
(verifies a trusted-proxy HMAC signature + origin allowlist and tags clientType), and
rateLimit (Redis-backed fixed-window limiter for brute-force / DoS protection).
These are the exports of this module.
defineMiddleware(custom named middleware),.use()/.skip()(route-level wiring), andTransactional(DB transaction middleware) are not here — they live in@spfn/core/routeand@spfn/core/db. See Related.
Import paths
import {
ErrorHandler,
RequestLogger,
maskSensitiveData,
createProxyGuard,
createCacheNonceStore,
rateLimit,
getClientIp,
} from '@spfn/core/middleware';
import type {
ErrorHandlerOptions,
OnErrorContext,
RequestLoggerOptions,
RequestLoggerConfig, // deprecated alias of RequestLoggerOptions
ProxyGuardConfig,
ProxyGuardMode,
ClientType,
NonceStore,
RateLimitOptions,
} from '@spfn/core/middleware';
There is no @spfn/core root barrel — always import from @spfn/core/middleware.
import { ErrorHandler } from '@spfn/core' does not resolve.
Public API (complete)
From @spfn/core/middleware:
ErrorHandler(options?: ErrorHandlerOptions)→(err, c) => Response | Promise<Response>— register withapp.onError(...), notapp.use(...).RequestLogger(options?: RequestLoggerOptions)→ Hono middleware(c, next) => Promise<void>— register withapp.use(...).maskSensitiveData(obj, sensitiveFields, seen?)→ deep-masked copy ofobj.createProxyGuard(config?: ProxyGuardConfig)→ Hono middleware. Verifies the proxy→backend HMAC signature (rotating key set, keyed bykeyId) overmethod+path+query+body, plus an optional origin allowlist, then setsc.get('clientType')('web'|'untrusted'). Modes:off(default) /tag/strict— every gate is evaluated in BOTH modes, only enforcement differs (strict rejects 403/413, tag tagsuntrustedand continues).maxBodyBytescaps the hashed body (stream-measured). Usually enabled viadefineServerConfig().proxyGuard({...}), notapp.usedirectly.createCacheNonceStore(cache, prefix?)→NonceStorefor hard replay rejection (RedisSET … PX NX). Pass toproxyGuard.nonceStore(auto-wired from a cache whennonce: true).rateLimit(options: RateLimitOptions)→ Hono middleware. Redis-backed fixed-window limiter, registered under the named'rateLimit'slot so routes can.skip(['rateLimit']). Attach with.use([rateLimit({...})]). See rateLimit.getClientIp(c)→ best-effort client IP from the proxy chain (leftmostX-Forwarded-For, thenX-Real-IP, else'unknown'). Spoofable — see the caveat in rateLimit.
Types: ErrorHandlerOptions, OnErrorContext, RequestLoggerOptions, RequestLoggerConfig,
ProxyGuardConfig, ProxyGuardMode, ClientType, NonceStore, RateLimitOptions.
See the root PROXY-BACKEND-AUTH-SPEC.md for the threat model, key rotation, and .env
placement (SPFN_PROXY_SECRET in .env.local; grace SPFN_PROXY_SECRET_PREVIOUS in .env.server).
RequestLoggerConfigis deprecated — it is a type alias ofRequestLoggerOptions. UseRequestLoggerOptionsin new code.
Most SPFN apps never call these directly.
createServer/defineServerConfigauto-register both — see Auto-registration below. Manualapp.use/app.onErroris the raw-Hono path.
Auto-registration (the SPFN default path)
When you start a server via @spfn/core/server, RequestLogger and ErrorHandler are
applied for you (along with CORS). You do not wire them by hand.
import { defineServerConfig } from '@spfn/core/server';
export default defineServerConfig()
.routes(appRouter)
.build();
// → RequestLogger() (no options), CORS, then ErrorHandler() are auto-applied.
Toggle / configure them through config.middleware:
export default defineServerConfig()
.middleware({
logger: true, // RequestLogger (default: true) — set false to disable
cors: true, // CORS (default: true)
errorHandler: true, // ErrorHandler (default: true) — set false to disable
onError: (err, ctx) => // forwarded into ErrorHandler({ onError })
{
// non-blocking side-effect (Slack, PagerDuty, ...)
log(ctx.statusCode, ctx.method, ctx.path, err.message);
},
})
.routes(appRouter)
.build();
Notes (from create-server.ts):
- The auto-applied
RequestLogger()is called with no options, so it uses the defaults below (excludes/health,/ping,/favicon.ico). To customize excludePaths etc., disable it (middleware.logger: false) and add your own viaconfig.use. config.middleware.onErroris the onlyErrorHandlerOptionexposed through the builder;includeStack/enableLoggingfall back to their defaults under auto-config.- Order is fixed:
RequestLogger→ CORS → routes →ErrorHandler(viaapp.onError).
Quick Start (raw Hono)
Use this only when wiring a bare Hono app yourself (not via defineServerConfig).
import { Hono } from 'hono';
import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
const app = new Hono();
app.use('*', RequestLogger()); // middleware — runs per request
app.onError(ErrorHandler()); // error hook — NOT app.use()
export default app;
ErrorHandler
Converts a thrown error into a JSON HTTP response. Register it with app.onError()
(it is Hono's error hook, not a use() middleware).
Behavior
SerializableError(from@spfn/core/errors) → serialized via itstoJSON(), using the error's ownstatusCodeas the HTTP status. Custom fields (resource,fields, …) are preserved. Detection is duck-typed (toJSON+ numericstatusCode) so it survives module duplication under tsx/dev.- Standard
Error→ falls back to{ __type: 'Error', message }, status from astatusCodeproperty on the error if present, else500. causechain → the root cause message is extracted and added ascause.- Production information disclosure → when
includeStackisfalse(the production default), the client message is genericized for anything whose text may come from the DB driver: standard (uncaught)Errors become"Internal Server Error"with nocause, and any error exposinginternal === true(theDatabaseErrorfamily —QueryError,ConnectionError,TransactionError, … carrying raw SQL / table / column / parameter text) becomes"Internal server error"with nodetails. Full detail is still logged server-side. Errors with a safe, constructed message (EntityNotFoundError,DuplicateEntryError, and all non-DBSerializableErrors) are returned unchanged. - Logging (when
enableLogging):warnfor 4xx,errorfor 5xx, via the@spfn/core:error-handlerlogger. onErrorcallback → fired non-blocking (Promise.resolve(...).catch(...)); never delays or fails the response.
Options (ErrorHandlerOptions)
| Option | Type | Default | Effect |
|---|---|---|---|
includeStack |
boolean |
env.NODE_ENV !== 'production' |
Add stack to the response body |
enableLogging |
boolean |
true |
Log errors (warn 4xx / error 5xx) |
onError |
(err, ctx: OnErrorContext) => void | Promise<void> |
— | Non-blocking side-effect callback |
app.onError(ErrorHandler({
includeStack: env.NODE_ENV !== 'production',
enableLogging: true,
onError: (err, ctx) => notify(ctx.statusCode, ctx.path, err),
}));
Response format
SerializableError (e.g. NotFoundError) — body is its toJSON() output; HTTP status is
the error's statusCode:
// production
{ "__type": "NotFoundError", "message": "User not found", "resource": "User" }
// development (includeStack) adds:
{ "__type": "NotFoundError", "message": "User not found", "resource": "User",
"stack": "Error: User not found\n at ..." }
Standard error fallback:
{ "__type": "Error", "message": "Internal Server Error" }
// + "cause": "..." when the error has a cause
// + "stack": "..." only when includeStack is true
The serialized field is
__type, and the status code is carried by the HTTP status, not a body field. (Older docs showing{ "error": "...", "statusCode": 400 }are stale.)
OnErrorContext
interface OnErrorContext {
statusCode: number;
path: string;
method: string;
requestId?: string; // present when RequestLogger ran first
timestamp: string; // ISO 8601
userId?: string; // from c.get('auth')?.userId, when auth middleware set it
request: {
headers: Record<string, string>; // sensitive headers masked to '***'
query: Record<string, string>;
};
}
Masked request headers: authorization, cookie, x-api-key, x-auth-token
(case-insensitive). requestId and userId are only populated when the corresponding
upstream middleware (RequestLogger / auth) has run.
RequestLogger
Per-request logging middleware. Register with app.use().
Behavior
- Generates a request ID (
req_<timestamp>_<6-byte hex>) and stores it on the context:c.set('requestId', id)→ read viac.get('requestId'). - Logs
Request received(method, path, ip, userAgent) andRequest completed(status, duration). Client IP is taken fromx-forwarded-for(first hop) →x-real-ip→'unknown'. - Log level by status:
info(<400),warn(4xx),error(5xx). Logger child:@spfn/core:api. - Slow requests (
duration >= slowRequestThreshold) getslow: true. - For 4xx/5xx: clones the response to attach the error
responsebody, and forPOST/PUT/PATCHattaches the masked request body (request). - If the downstream throws, logs
Request failedaterrorlevel and re-throws (soapp.onError/ErrorHandlerstill runs). It does not swallow errors.
Options (RequestLoggerOptions)
| Option | Type | Default |
|---|---|---|
excludePaths |
string[] |
['/health', '/ping', '/favicon.ico'] |
sensitiveFields |
string[] |
['password', 'token', 'apiKey', 'secret', 'authorization'] |
slowRequestThreshold |
number (ms) |
1000 |
excludePaths matches exact or prefix — /health also excludes /health/db. Excluded
paths skip logging entirely (and get no request ID).
app.use('*', RequestLogger({
excludePaths: ['/health', '/metrics', '/_next'],
sensitiveFields: ['password', 'creditCard', 'ssn'],
slowRequestThreshold: 500,
}));
Reading the request ID
// in a route handler
const requestId = c.get('requestId'); // string | undefined
Log output examples
// Request received
{ "level": "info", "module": "api", "msg": "Request received",
"requestId": "req_1759541628730_qsm7esvo7", "method": "POST", "path": "/users",
"ip": "127.0.0.1", "userAgent": "..." }
// completed (success)
{ "level": "info", "module": "api", "msg": "Request completed",
"requestId": "req_...", "method": "POST", "path": "/users", "status": 201, "duration": 45 }
// completed (4xx — includes response body + masked request body)
{ "level": "warn", "module": "api", "msg": "Request completed",
"status": 400, "duration": 2,
"response": { "__type": "ValidationError", "message": "Invalid request body" },
"request": { "status": 123, "password": "***MASKED***" } }
// slow
{ "level": "info", "msg": "Request completed", "status": 200, "duration": 1250, "slow": true }
// downstream threw (then re-thrown to ErrorHandler)
{ "level": "error", "module": "api", "msg": "Request failed",
"method": "POST", "path": "/users", "duration": 23, "error": { ... } }
maskSensitiveData
Deep-masks fields whose name (case-insensitive) contains any of sensitiveFields,
returning a new structure. Used internally by RequestLogger; exported for reuse.
import { maskSensitiveData } from '@spfn/core/middleware';
maskSensitiveData(
{ username: 'john', password: 'secret', apiKey: 'sk_live_x' },
['password', 'apiKey'],
);
// → { username: 'john', password: '***MASKED***', apiKey: '***MASKED***' }
- Partial + case-insensitive:
['password']masksuserPassword,PASSWORD, etc. - Recursive: descends into nested objects and arrays.
- Immutable: shallow-clones at each level; the input is untouched.
- Circular-safe: repeated references become
'[Circular]'(via an internalWeakSet). - Non-objects (
null, primitives) are returned as-is.
Replacement token is the literal string '***MASKED***'.
rateLimit
Redis-backed fixed-window rate limiter. Three ways to apply it: attach per-route with
.use([rateLimit({...})]); enable a global default for every route;
or tag a route with a named policy the
consuming app tunes centrally. The global default and policy tags use the named 'rateLimit'
middleware, so routes opt out with .skip(['rateLimit']).
import { rateLimit, getClientIp } from '@spfn/core/middleware';
// 10 requests / minute per client IP (the default dimension)
route.post('/_auth/login')
.use([rateLimit({ limit: 10, windowMs: 60_000 })])
.handler(/* ... */);
// limit on more than one dimension — the strictest wins
route.post('/_auth/codes')
.use([rateLimit({
limit: 5,
windowMs: 60_000,
by: (c) => [getClientIp(c)],
})])
.handler(/* ... */);
Options (RateLimitOptions)
| Field | Type | Default | Notes |
|---|---|---|---|
limit |
number |
— | Max requests per window, applied to each dimension. |
windowMs |
number |
— | Window length in milliseconds. |
scope |
string |
`${method} ${routePath}` |
Counter-key namespace; defaults to per-route. |
by |
(c) => (Dimension | null | undefined)[] |
[getClientIp(c)] |
Identity dimensions; each non-empty value is counted separately, strictest wins. A Dimension is a string (uses limit) or { key, limit? } to give that dimension its own limit. |
Per-dimension limits let one limiter be loose on IP and tight on account/target — so a shared NAT isn't throttled as one user while a single account stays protected:
rateLimit({
limit: 5, // default for dimensions without their own limit
windowMs: 60_000,
by: (c) => [
{ key: `ip:${getClientIp(c)}`, limit: 100 }, // loose per-IP floor
accountKey(c), // tight per-account (uses limit: 5)
],
});
@spfn/auth ships byIpAndAccount() / byIpAndTarget() builders for exactly this on its
login, register, and verification-code routes (per-account / per-target tight, per-IP loose),
so distributed brute force and SMS-bombing are limited by the thing being attacked, not only
by source IP.
| failClosed | boolean | false | Reject with 429 when the cache is unavailable instead of allowing through. |
| message | string | generic | 429 response message. |
Behavior
- Atomic: counts via a single Lua
INCR+PEXPIRE, so the expiry is never lost in a race between two requests. - Fail-open by default: when no cache is configured (or it is disabled), requests pass
and a warning is logged — matching the proxy-guard nonce store's graceful degradation, so
local dev without Redis still works. Set
failClosed: trueto reject instead. - On exceed: throws
TooManyRequestsError(429) and sets aRetry-Afterheader derived from the key's remaining TTL. - Storage: keys are
ratelimit:{scope}:{dimension}in the shared cache.
IP trust caveat:
getClientIpreads the leftmostX-Forwarded-Forhop, which a client can spoof unless a trusted proxy overwrites it. For security-sensitive limits, pair the IP dimension with an account/target dimension rather than relying on IP alone.
Global default limiter
Turn on a default limiter for every route from one place — no per-route .use(). It is
registered as the named 'rateLimit' middleware, so a route opts out with .skip(['rateLimit']).
Disabled by default (mode: 'off').
export default defineServerConfig()
.rateLimit({
mode: 'on',
default: { limit: 100, windowMs: 60_000 },
})
.routes(appRouter)
.build();
Or via env: RATE_LIMIT_MODE=on, RATE_LIMIT_DEFAULT_LIMIT, RATE_LIMIT_DEFAULT_WINDOW_MS,
RATE_LIMIT_FAIL_CLOSED. Health, SSE and WebSocket endpoints register outside the
named-middleware pipeline, so they are always exempt.
Named policies — rateLimitPolicy(name, fallback)
Lets a package tag a sensitive route while the consuming app tunes the numbers centrally. The package ships the tag with a safe fallback; the app overrides it by name.
// in a package (e.g. @spfn/auth)
route.post('/_auth/login')
.use([rateLimitPolicy('auth-login', { limit: 5, windowMs: 60_000 })])
.handler(/* ... */);
// in the consuming app — tune every policy in one place
export default defineServerConfig()
.rateLimit({
policies: {
'auth-login': { limit: 10, windowMs: 60_000 },
},
})
.routes(appRouter)
.build();
- Resolution: configured policy (by name) wins, shallow-merged over the fallback; with no config the fallback applies, so the route is protected out of the box.
- Layered: the tag registers under a distinct name (
rateLimit:<name>) and does not skip the global default, so a tagged route gets both — the global per-IP floor (when enabled) and this policy's own bucket; whichever is stricter trips first. Opt out of the floor on a route with.skip(['rateLimit']). - Shared bucket: a policy's counter scope defaults to its name, so every route sharing a
policy shares one bucket (e.g. all OAuth start routes share
oauth-start). It also keeps the key from colliding with the global default's per-route scope. Pass an explicitscopeto override. - Policies are registered at server boot whether or not the global default is enabled.
Limitations & operational notes
Read these before relying on rate limiting as a security control:
- Default identity is the client IP. A limiter with no
bykeys only ongetClientIp(c), so a distributed attacker (many source IPs) isn't stopped per-account and a shared NAT/CGNAT can be throttled as one user. For account/target protection (credential stuffing, OTP/SMS-bombing) add abydimension returning a stable account/target key alongside the IP — ideally with a per-dimension limit (loose IP, tight account). The bundled@spfn/authlogin/register/code routes already do this viabyIpAndAccount()/byIpAndTarget(). getClientIptrusts forwarded headers. Behind a verified proxy (proxy-guard) it uses the real client IP; otherwise it readsX-Forwarded-For/X-Real-IP, which a direct client can spoof (rotate to bypass, or pin to a victim to DoS them). With no forwarding header it falls back to the TCP peer address (Node adapter), and only to the literal'unknown'when even that is unavailable (non-Node runtime). Still: enable the global default behind a proxy that sets a trustworthy client IP, since the header — when present — is taken on trust.- Fail-open by default. When the cache (Redis) is down, limiters pass requests through.
Set
RATE_LIMIT_FAIL_CLOSED=trueto reject instead — this now applies to both the global default and named policy tags (a tag may still opt back to fail-open withfailClosed: false). - Counter scopes differ by layer. The global default is keyed per-route
(
${method} ${routePath}); a named policy is keyed by its name, so routes sharing a policy share one bucket. Pass an explicitscopeto change either. - The SSE token endpoint is not exempt. Only the SSE stream, WebSocket, and health
endpoints (registered outside the named-middleware pipeline) bypass the global default;
POST /events/tokenrunsconfig.middlewares, so it receives the limiter too.
Pitfalls & anti-patterns
ErrorHandlergoes onapp.onError(), neverapp.use(). It returns an(err, c) => Responseerror hook, not a(c, next)middleware. Putting it inuse()(orconfig.middlewares/config.use) will not catch errors.- Don't double-register under
defineServerConfig. The server auto-applies both. Only use the rawapp.use(RequestLogger())/app.onError(ErrorHandler())calls on a bare Hono app. To change RequestLogger options under SPFN, setmiddleware.logger: falseand add your own viaconfig.use. config.middleware.onErroris the only ErrorHandler option the builder forwards.includeStack/enableLoggingare not configurable throughdefineServerConfig— they use defaults. Need them tuned? Build the Hono app manually.- RequestLogger must run before ErrorHandler for
requestIdto appear inOnErrorContext. The auto-config order (logger → … → onError) already guarantees this; preserve it if wiring manually (app.use(RequestLogger())thenapp.onError(...)). - Excluded paths get no request ID.
excludePathsshort-circuits beforec.set('requestId'), so handlers on/healthetc. readundefined. - These are not "named middleware." They have no
.skip()name and cannot be skipped per-route via the route DSL. Route-level skip applies only toNamedMiddlewarecreated withdefineMiddleware(@spfn/core/route). To exclude paths from logging, useexcludePaths, not.skip(). sensitiveFieldsmatches by substring. A field namedtokenizeis masked because it containstoken. Choose field names with that in mind.- Don't import from
@spfn/core. No root barrel exists; use@spfn/core/middleware. RequestLoggerConfigis deprecated — alias ofRequestLoggerOptions.
Complete example (raw Hono)
import { Hono } from 'hono';
import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
import { NotFoundError } from '@spfn/core/errors';
const app = new Hono();
// 1. RequestLogger first — assigns requestId, times every request
app.use('*', RequestLogger({
excludePaths: ['/health'],
slowRequestThreshold: 500,
}));
app.get('/users/:id', async (c) =>
{
const requestId = c.get('requestId');
const user = await findUser(c.req.param('id'));
if (!user)
{
throw new NotFoundError({ message: 'User not found', resource: 'User' });
}
return c.json({ requestId, user });
});
// 2. ErrorHandler last — onError hook catches everything above
app.onError(ErrorHandler({
includeStack: process.env.NODE_ENV !== 'production',
onError: (err, ctx) => notify(ctx),
}));
export default app;
Under SPFN, the equivalent is just defineServerConfig().routes(appRouter).build() — both
middleware are added automatically.
Types reference
interface ErrorHandlerOptions {
includeStack?: boolean; // default: env.NODE_ENV !== 'production'
enableLogging?: boolean; // default: true
onError?: (err: Error, context: OnErrorContext) => Promise<void> | void;
}
interface OnErrorContext {
statusCode: number;
path: string;
method: string;
requestId?: string;
timestamp: string;
userId?: string;
request: { headers: Record<string, string>; query: Record<string, string> };
}
interface RequestLoggerOptions {
excludePaths?: string[]; // default: ['/health', '/ping', '/favicon.ico']
sensitiveFields?: string[]; // default: ['password','token','apiKey','secret','authorization']
slowRequestThreshold?: number; // default: 1000 (ms)
}
type RequestLoggerConfig = RequestLoggerOptions; // @deprecated
function maskSensitiveData(obj: any, sensitiveFields: string[], seen?: WeakSet<object>): any;
Related
- @spfn/core/route —
defineMiddleware/defineMiddlewareFactory(custom named middleware), route-level.use()/.skip()wiring and execution order. - @spfn/core/db —
Transactional()route middleware (auto commit/rollback). - @spfn/core/server —
defineServerConfig/config.middleware, which auto-registersRequestLogger+ErrorHandler. - @spfn/core/errors —
SerializableErrorand the built-in error classes thatErrorHandlerserializes. - @spfn/core/logger — the logger both middleware write to.