Skip to main content

Configuration — shapeguard

Every config option. Global vs scoped. Defaults. Override patterns.


Global vs scoped

GLOBAL — set once in shapeguard(), applies everywhere
logger behaviour (level, pretty, body logging, redaction)
validation limits and error exposure
response shape and status codes
error fallback message and hooks

SCOPED — set per-route in validate() or withShape()
which schemas to validate on this route
per-route limit overrides (larger/smaller than global)
per-route sanitize config
response shape for this route only
// GLOBAL — in app.ts
app.use(shapeguard({
logger: { level: 'warn', slowThreshold: 1000 },
validation: { exposeEnumValues: false },
response: { includeRequestId: true },
errors: { fallbackMessage: 'Something went wrong' },
}))

// SCOPED — per route
validate({ body: CreateUserBodySchema, limits: { maxStringLength: 500 } })

shapeguard() config reference

app.use(shapeguard({

// ── debug mode ─────────────────────────────────────────────────
// Controls error detail exposure and log verbosity.
// Default: auto-detected from NODE_ENV
// NODE_ENV !== 'production' → debug: true
// NODE_ENV === 'production' → debug: false
debug: false,

// ── request ID ─────────────────────────────────────────────────
// Controls how req.id is generated and where it comes from.
requestId: {
// Generate a unique ID for every request (default: true).
// Set false to disable — req.id will be '' and [req_id] won't appear in logs.
enabled: true,

// Header to read the request ID from BEFORE generating one.
// Use this when a load balancer / API gateway already set a trace ID
// so the same ID flows through all your services.
// Default: 'x-request-id'. Also common: 'x-trace-id', 'x-correlation-id'.
// Falls back to generating a fresh ID if the header is absent.
header: 'x-request-id',

// Custom ID generator — replaces the built-in req_<timestamp><random> format.
// Must return a non-empty string. Called once per request.
// generator: () => `trace-${crypto.randomUUID()}`,
},

// ── logger ─────────────────────────────────────────────────────
logger: {

// Bring your own — any { info, warn, error, debug } interface.
// pino, winston, console all work.
// When provided, all other logger options are ignored.
instance: yourLoggerInstance,

// Log level. Default: 'debug' dev / 'warn' prod.
level: 'warn', // 'debug' | 'info' | 'warn' | 'error'

// Pretty-print (pino-pretty). Default: true dev / false prod.
pretty: false,

// Log every request including successful 2xx.
// Default: true dev / false prod
// false = only errors (4xx/5xx) and slow requests are logged
logAllRequests: false,

// Show >> arrival lines (default: true).
// false = hide arrival lines, keep only << response lines
logIncoming: false,

// Show [req_id] on every log line.
// Default: true — set false to hide request ID from log output.
// (separate from response.includeRequestId which controls the HTTP header)
logRequestId: true,

// Show only last 8 characters of request ID in log output.
// Full ID still generated and forwarded in X-Request-Id header.
// Default: false
shortRequestId: true,

// Log client IP on each response line.
// Reads x-forwarded-for first, then socket.remoteAddress.
// Default: false
logClientIp: true,

// Line colour mode in dev/pretty output.
// 'method' (default): GET=green, POST=cyan, DELETE=red
// 'level': 2xx=green, 4xx=yellow, 5xx=red
lineColor: 'level',

// Flag requests slower than this many milliseconds.
// 0 = disabled entirely.
// Default: 500 dev / 1000 prod
slowThreshold: 1000,

// Include req.body in the request log entry.
// Sensitive keys (password, token, secret etc) always redacted.
// Default: false — bodies often contain PII, enable with care
logRequestBody: false,

// Include the response JSON body in the log entry.
// Default: false — may contain PII or large payloads
logResponseBody: false,

// Additional field paths to redact from logs.
// Appended to built-in list — never replaces it.
// Always-redacted: password, passwordHash, token, secret, accessToken,
// refreshToken, apiKey, cardNumber, cvv, ssn,
// req.headers.authorization, req.headers.cookie
redact: [
'req.body.dateOfBirth',
'req.body.nationalId',
],
},

// ── validation ─────────────────────────────────────────────────
validation: {

// Global string transforms — applied to every string field in every schema.
// Saves repeating .trim() / .toLowerCase() on each field individually.
// Default: both false
strings: {
trim: true, // auto-trim whitespace from every string field
lowercase: false, // auto-lowercase every string field
},

// Show the field name in validation errors.
// Default: true always (field names are client input — safe to show)
exposeFieldName: true,

// Show the human-readable error message.
// Default: true always
exposeMessage: true,

// Show enum option values in errors like "Expected 'admin' | 'user'".
// Default: false prod / true dev (enum values can reveal your data model)
exposeEnumValues: false,

// Show raw Zod error codes like 'invalid_type', 'too_small'.
// Default: false always (reveals internal schema library)
exposeZodCodes: false,

// Pre-parse limits — apply before any schema runs.
// These protect against DoS and proto pollution.
limits: {
maxDepth: 20, // object nesting levels
maxArrayLength: 1000, // items in any array
maxStringLength: 10_000, // characters in any string field
},
},

// ── response ───────────────────────────────────────────────────
response: {

// Rename envelope fields globally.
// Available tokens: {success}, {data}, {message}
shape: {
status: '{success}', // success → status
result: '{data}', // data → result
msg: '{message}', // message → msg
},

// Override default HTTP status code per method.
statusCodes: {
POST: 201, // default
GET: 200, // default
PUT: 200, // default
PATCH: 200, // default
DELETE: 200, // default
},

// Add X-Request-Id header to every response.
// Useful for client-side error reporting.
// Default: false
includeRequestId: false,
},

// ── errors ─────────────────────────────────────────────────────
errors: {

// Message shown to clients for programmer errors (5xx non-AppError) in prod.
// Default: 'Something went wrong'
fallbackMessage: 'Something went wrong',

// Hook called after every error, before response is sent.
// Use for Sentry, Datadog, PagerDuty, alerting.
// Never throws — if the hook throws, it is silently ignored.
onError: (err: AppError, req: Request) => {
Sentry.captureException(err, {
extra: { requestId: req.id, path: req.path }
})
},
},

}))

validate() config reference

Scoped to one route. Never affects other routes.

// full route bundle from defineRoute()
validate(CreateUserRoute)

// individual schemas
validate({
body: CreateUserBodySchema,
params: UserParamsSchema,
query: UserQuerySchema,
headers: UserHeadersSchema,
sends: UserResponseSchema, // strips response fields
})

// return all validation errors in one part, not just the first
validate({
body: CreateUserBodySchema,
allErrors: true,
})

// override pre-parse limits for this route only
validate({
body: FileUploadSchema,
limits: { maxStringLength: 500_000 }, // larger for file routes
})

// override validation error exposure for this route
validate({
body: LoginSchema,
sanitize: { exposeEnumValues: false }, // hide enum values on this route
})

validate() options

OptionTypeDefaultDescription
bodyZodSchema | SchemaAdapterValidate + type req.body
paramsZodSchema | SchemaAdapterValidate + type req.params
queryZodSchema | SchemaAdapterValidate + type req.query
headersZodSchema | SchemaAdapterValidate headers
sends / responseZodSchema | SchemaAdapterStrip response fields
allErrorsbooleanfalseReturn all errors in one part
limits.maxDepthnumberglobal (20)Per-route nesting limit
limits.maxArrayLengthnumberglobal (1000)Per-route array limit
limits.maxStringLengthnumberglobal (10000)Per-route string limit
sanitize.exposeFieldNamebooleanglobal (true)Show field in error
sanitize.exposeMessagebooleanglobal (true)Show message in error
sanitize.exposeEnumValuesbooleanglobalShow enum options
sanitize.exposeZodCodesbooleanglobal (false)Show Zod codes

errorHandler() config

app.use(errorHandler({
// message for programmer errors in prod
fallbackMessage: 'Something went wrong',

// hook fires after every error, before response sent
onError: (err: AppError, req: Request) => {
if (err.statusCode >= 500) alertingService.critical(err)
},
}))

Note: errorHandler() has its own config separate from shapeguard(). The errors: block in shapeguard({ errors: {...} }) configures shapeguard's internal middleware. You still pass separate config to errorHandler().


notFoundHandler() config

// basic — message includes method + path
app.use(notFoundHandler())
// "Cannot GET /api/unknown"

// custom fixed message
app.use(notFoundHandler({ message: 'Route not found' }))

createRouter() config

Drop-in for express.Router(). Accepts all Express router options.

const router = createRouter()
const router = createRouter({ strict: false, mergeParams: true })

Automatically returns 405 with Allow header for registered paths used with wrong HTTP method. Works with parameterized routes like /:id.


Quick reference table

shapeguard() — requestId options

OptionTypeDefaultDescription
requestId.enabledbooleantrueGenerate request IDs
requestId.headerstring'x-request-id'Upstream header to read first
requestId.generator() => stringbuilt-inCustom ID generator

shapeguard() — logger options

OptionTypeDefaultDescription
logger.instanceLoggerbuilt-inCustom logger
logger.levelstring'debug' dev / 'warn' prodLog level
logger.prettybooleantrue dev / false prodpino-pretty format
logger.logAllRequestsbooleantrue dev / false prodLog every 2xx
logger.logIncomingbooleantrueShow >> arrival lines
logger.logRequestIdbooleantrueShow [req_id] in log lines
logger.shortRequestIdbooleanfalseShow last 8 chars of req ID only
logger.logClientIpbooleanfalseLog client IP on response lines
logger.lineColor'method' | 'level''method'Line colour mode
logger.slowThresholdnumber500 dev / 1000 prodSlow warn ms (0=off)
logger.logRequestBodybooleanfalseLog req.body (redacted)
logger.logResponseBodybooleanfalseLog response JSON (redacted)
logger.redactstring[][]Extra redact paths

shapeguard() — validation options

OptionTypeDefaultDescription
validation.strings.trimbooleanfalseAuto-trim all string fields
validation.strings.lowercasebooleanfalseAuto-lowercase all string fields
validation.exposeFieldNamebooleantrueField name in errors
validation.exposeMessagebooleantrueMessage in errors
validation.exposeEnumValuesbooleanfalse prodEnum values in errors
validation.exposeZodCodesbooleanfalseZod codes in errors
validation.limits.maxDepthnumber20Max nesting depth
validation.limits.maxArrayLengthnumber1000Max array size
validation.limits.maxStringLengthnumber10000Max string chars

shapeguard() — response options

OptionTypeDefaultDescription
response.shapeobjectdefault envelopeRename envelope fields
response.statusCodesobject{POST:201,*:200}Status per method
response.includeRequestIdbooleanfalseX-Request-Id header

shapeguard() — errors options

OptionTypeDefaultDescription
errors.fallbackMessagestring'Something went wrong'5xx message in prod
errors.onErrorfunctionHook for Sentry / alerting

AppError factories

FactoryStatusCode
AppError.notFound(resource?)404NOT_FOUND
AppError.unauthorized(msg?)401UNAUTHORIZED
AppError.forbidden(msg?)403FORBIDDEN
AppError.conflict(resource?)409CONFLICT
AppError.validation(issues)422VALIDATION_ERROR
AppError.internal(msg?)500INTERNAL_ERROR
AppError.custom(code,msg,status,details?)anyany
AppError.fromUnknown(err)variesvaries
AppError.fromLegacy({code,message,statusCode})anyany

res helpers

HelperStatusNotes
res.ok(opts)200 (configurable)General success
res.created(opts)201 (always)POST created
res.accepted(opts)202 (always)Async job accepted
res.noContent()204 (always)No body
res.paginated(opts)200List with pagination metadata
res.fail(opts)400 (configurable)Inline error response

Per-route rate limiting — rateLimit

Available on defineRoute()

Built-in rate limiting. No extra package needed. Applied per IP + route path by default.

⚠️ Single-process only. The built-in store is an in-memory Map per route. In multi-process deployments (PM2 cluster, Kubernetes pods), each process maintains its own counter — effective limit is max × processes. For distributed rate limiting, pass a Redis store: defineRoute({ rateLimit: { windowMs, max, store: myRedisStore } })

⚠️ IP spoofing without trust proxy. The default key uses x-forwarded-for, which is spoofable. Set app.set('trust proxy', 1) before shapeguard() for correct IP detection behind a load balancer.

defineRoute({
body: CreateUserDTO,
rateLimit: {
windowMs: 60_000, // time window in milliseconds (60s here)
max: 10, // max requests per window per key
message: 'Too many requests — please try again later', // optional

// ── Advanced: plug in Redis or any external store ──────────
// Default is in-memory (single instance). For multi-instance
// production apps, provide a Redis-backed store:
store: {
async get(key: string) {
const raw = await redis.get(key)
return raw ? JSON.parse(raw) : null
},
async set(key: string, value: { count: number; reset: number }) {
const ttl = Math.ceil((value.reset - Date.now()) / 1000)
await redis.set(key, JSON.stringify(value), 'EX', ttl)
},
},

// ── Advanced: custom key generator ─────────────────────────
// Default key is: `${req.path}:${clientIP}`
// Override to key by user ID, API key, tenant, etc.:
keyGenerator: (req) => req.user?.id ?? req.ip,
}
})

Rate limit error response

When exceeded, shapeguard throws a 429 with ErrorCode.RATE_LIMIT_EXCEEDED:

{
"success": false,
"message": "Too many requests — please try again later",
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests — please try again later",
"details": { "retryAfter": 42 }
}
}

In-memory store (default)

The default store is per-process. It works perfectly for:

  • Single-instance apps
  • Development and testing
  • Low-traffic endpoints

For high-traffic production multi-instance deployments, provide a Redis store as shown above.


Per-route cache hints — cache

Available on defineRoute()

Sets Cache-Control response headers declaratively — no manual res.setHeader needed. Cache headers are only set on successful responses — validation errors (422) are never cached.

// Public cache — CDN and browser cache for 60 seconds
defineRoute({
params: UserParamsSchema,
response: UserResponseSchema,
cache: { maxAge: 60 },
})
// → Cache-Control: public, max-age=60

// Private cache — browser only, not CDN
defineRoute({
cache: { maxAge: 300, private: true },
})
// → Cache-Control: private, max-age=300

// CDN-optimised — separate TTL for browser vs CDN
defineRoute({
cache: { maxAge: 60, sMaxAge: 300, staleWhileRevalidate: 30 },
})
// → Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30

// No store — sensitive endpoints (auth, payments)
// maxAge is not required when noStore is true
defineRoute({
cache: { noStore: true },
})
// → Cache-Control: no-store

Options

OptionTypeDescription
maxAgenumberBrowser TTL in seconds
privatebooleanBrowser-only — CDN must not cache
noStorebooleanNever cache anywhere. When true, maxAge is optional
sMaxAgenumberCDN TTL in seconds (overrides maxAge for CDNs)
staleWhileRevalidatenumberServe stale content for N seconds while revalidating in background

When to use each

PatternUse case
{ maxAge: 60 }Public API data — product listings, blog posts
{ maxAge: 300, private: true }User-specific data — profile, dashboard
{ maxAge: 60, sMaxAge: 3600 }High-traffic public API — short browser TTL, long CDN TTL
{ maxAge: 60, staleWhileRevalidate: 60 }Frequently updated data — serve stale while refreshing
{ noStore: true }Sensitive — auth tokens, payment pages, personal data

Webhook signature verification — verifyWebhook()

Standalone middleware — works without any other shapeguard feature. See ERRORS.md for webhook error codes.

Verify HMAC signatures on incoming webhook payloads. Uses crypto.timingSafeEqual() to prevent timing attacks. Zero dependencies — Node.js built-in crypto only.

import { verifyWebhook } from 'shapeguard'

// Built-in provider presets — algorithm, header, prefix, replay protection all handled
router.post('/webhooks/stripe',
express.raw({ type: 'application/json' }), // raw body needed for HMAC
verifyWebhook({ provider: 'stripe', secret: process.env.STRIPE_WEBHOOK_SECRET! }),
handler,
)

router.post('/webhooks/github',
express.raw({ type: 'application/json' }),
verifyWebhook({ provider: 'github', secret: process.env.GITHUB_WEBHOOK_SECRET! }),
handler,
)

router.post('/webhooks/shopify',
express.raw({ type: 'application/json' }),
verifyWebhook({ provider: 'shopify', secret: process.env.SHOPIFY_WEBHOOK_SECRET! }),
handler,
)

// Custom provider
router.post('/webhooks/custom',
express.raw({ type: 'application/json' }),
verifyWebhook({
secret: process.env.MY_SECRET!,
algorithm: 'sha256',
headerName: 'x-my-signature',
prefix: 'sha256=',
encoding: 'hex',
onFailure: (req, reason) => logger.warn({ reason }, 'Webhook verification failed'),
}),
handler,
)

Built-in providers

ProviderAlgorithmHeaderReplay protection
stripeSHA-256stripe-signature✅ 5-minute window
githubSHA-256x-hub-signature-256
shopifySHA-256x-shopify-hmac-sha256
twilioSHA-1x-twilio-signature
svixSHA-256svix-signature✅ 5-minute window

Config options

OptionTypeDescription
provider'stripe' | 'github' | 'shopify' | 'twilio' | 'svix'Built-in preset
secretstringWebhook signing secret from the provider
algorithmstringHMAC algorithm (default: 'sha256')
headerNamestringHeader containing the signature
prefixstringPrefix to strip before comparing (e.g. 'sha256=')
encoding'hex' | 'base64'Signature encoding (default: 'hex')
toleranceSecsnumberReplay attack window in seconds (default: 300)
onSuccess(req) => voidCalled after successful verification
onFailure(req, reason) => voidCalled on failure — use for alerting

Error codes

CodeHTTPWhen
WEBHOOK_SIGNATURE_MISSING400Signature header not present
WEBHOOK_SIGNATURE_INVALID401HMAC mismatch
WEBHOOK_TIMESTAMP_MISSING400Timestamp field absent (Stripe/Svix only)
WEBHOOK_TIMESTAMP_EXPIRED400Timestamp outside tolerance window — replay attack