hapi-aegis
v1.5.0
Published
A Hapi.js plugin that sets security-related HTTP response headers.
Maintainers
Readme
hapi-aegis
A Hapi.js plugin that sets security-related HTTP response headers — one plugin, sensible defaults, per-middleware and per-route configuration.
Why hapi-aegis?
The Node security-header landscape is dominated by Helmet, which is Express-only. Hapi developers who want the same coverage today either wire up several single-purpose plugins or hand-roll an onPreResponse extension that sets a dozen headers and juggles Boom error responses. hapi-aegis fills that gap: one plugin, every common header, with first-class support for Hapi conventions — plugins.aegis route overrides, Boom-aware response handling, and zero runtime dependencies beyond the @hapi/hapi peer. It is inspired by Helmet, but built natively for Hapi rather than ported.
Quick Start
npm install hapi-aegisconst Hapi = require('@hapi/hapi');
const Aegis = require('hapi-aegis');
const server = Hapi.server({ host: 'localhost', port: 3000 });
await server.register(Aegis);
await server.start();That's it — every response now carries a secure baseline set of headers. See examples/basic.js and examples/custom.js for runnable servers.
API Reference
Register with options to customise any middleware. Pass false to any middleware key to disable it entirely, or pass an options object to override its defaults.
await server.register({
plugin: require('hapi-aegis'),
options: {
hsts: { maxAge: 63072000, preload: true },
frameguard: { action: 'deny' },
xssFilter: false
}
});Middleware summary
| Middleware | Header | Default | Options |
|---|---|---|---|
| clearSiteData | Clear-Site-Data | not set (opt-in) | directives |
| contentSecurityPolicy | Content-Security-Policy | built-in directive set (see Content Security Policy) | directives, useDefaults, reportOnly |
| crossOriginEmbedderPolicy | Cross-Origin-Embedder-Policy | require-corp | policy |
| crossOriginOpenerPolicy | Cross-Origin-Opener-Policy | same-origin | policy |
| crossOriginResourcePolicy | Cross-Origin-Resource-Policy | same-origin | policy |
| dnsPrefetchControl | X-DNS-Prefetch-Control | off | allow |
| expectCt | Expect-CT | max-age=0 | maxAge, enforce, reportUri (deprecated — see FAQ) |
| frameguard | X-Frame-Options | SAMEORIGIN | action |
| hidePoweredBy | removes X-Powered-By and Server | — | none (boolean) |
| hsts | Strict-Transport-Security | max-age=15552000; includeSubDomains | maxAge, includeSubDomains, preload |
| ieNoOpen | X-Download-Options | noopen | none (boolean) |
| noSniff | X-Content-Type-Options | nosniff | none (boolean) |
| originAgentCluster | Origin-Agent-Cluster | ?1 | none (boolean) |
| permissionsPolicy | Permissions-Policy | 8 sensor/device features denied | features |
| permittedCrossDomainPolicies | X-Permitted-Cross-Domain-Policies | none | permittedPolicies |
| referrerPolicy | Referrer-Policy | no-referrer | policy |
| reportingEndpoints | Reporting-Endpoints | not set (opt-in) | map of endpoint name → URL |
| reportTo | Report-To | not set (opt-in) | group, maxAge, endpoints, includeSubdomains |
| xssFilter | X-XSS-Protection | 0 | none (boolean) — see FAQ |
clearSiteData
Sets Clear-Site-Data, which instructs the browser to clear data (cookies, cache, storage, execution contexts) associated with the origin. Opt-in: when no clearSiteData option is provided, the header is not emitted.
This header is destructive and intended for specific endpoints such as logout, so configure it per route via plugins.aegis.clearSiteData rather than enabling it server-wide. Browsers only honour it on secure (HTTPS) responses.
directives(array, required) — non-empty list of tokens, each one of'cache','cookies','storage','executionContexts', or'*'(wildcard for all types). Rendered as comma-separated, double-quoted values, e.g."cache", "cookies".
Apply it to a logout route while leaving the rest of the site untouched:
server.route({
method: 'POST',
path: '/logout',
options: {
plugins: {
aegis: {
clearSiteData: { directives: ['cache', 'cookies', 'storage'] }
}
}
},
handler: (request, h) => {
request.cookieAuth.clear();
return h.redirect('/');
}
});contentSecurityPolicy
Sets Content-Security-Policy (or Content-Security-Policy-Report-Only when reportOnly is true). See the Content Security Policy section for the default directives, merging rules, and warnings.
directives(object) — map of camelCase directive names to a string or array of sources.useDefaults(boolean, defaulttrue) — merge your directives with the built-in defaults; whenfalse, only your directives are used.reportOnly(boolean, defaultfalse) — switch the header name toContent-Security-Policy-Report-Only.
crossOriginEmbedderPolicy
Sets Cross-Origin-Embedder-Policy.
policy(string, default'require-corp') — one ofrequire-corp,credentialless,unsafe-none.
crossOriginOpenerPolicy
Sets Cross-Origin-Opener-Policy.
policy(string, default'same-origin') — one ofsame-origin,same-origin-allow-popups,unsafe-none.
crossOriginResourcePolicy
Sets Cross-Origin-Resource-Policy.
policy(string, default'same-origin') — one ofsame-origin,same-site,cross-origin.
dnsPrefetchControl
Sets X-DNS-Prefetch-Control.
allow(boolean, defaultfalse) — whentrueemitson, otherwiseoff.
expectCt
Sets Expect-CT. The underlying header is deprecated by browsers; this middleware exists for legacy compatibility and is easy to disable with expectCt: false.
maxAge(integer, default0) — non-negative seconds.enforce(boolean, defaultfalse) — adds theenforcedirective.reportUri(string, optional) — quoted in the header value.
frameguard
Sets X-Frame-Options.
action(string, default'sameorigin') —denyorsameorigin(case-insensitive; emitted in upper case).
hidePoweredBy
Removes X-Powered-By and Server from responses. No options — enable or disable with true / false.
hsts
Sets Strict-Transport-Security.
maxAge(integer, default15552000— 180 days) — non-negative seconds.includeSubDomains(boolean, defaulttrue) — addsincludeSubDomains.preload(boolean, defaultfalse) — addspreload; only use if you intend to submit to the HSTS preload list.
ieNoOpen
Sets X-Download-Options: noopen. No options.
noSniff
Sets X-Content-Type-Options: nosniff. No options.
originAgentCluster
Sets Origin-Agent-Cluster: ?1. No options.
permissionsPolicy
Sets Permissions-Policy (formerly Feature-Policy) to control which browser features can be used on the page and in embedded iframes.
features(object, optional) — map of feature names (camelCase, converted to kebab-case in the header) to an allowlist array:[]— feature fully denied.['*']— feature allowed on all origins.['self']— feature allowed only on same origin.['self', 'https://maps.example.com']— feature allowed on same origin plus the listed origins.
When features is not supplied, a conservative set of high-risk sensor/device features is denied:
accelerometer=(), camera=(), geolocation=(), gyroscope=(),
magnetometer=(), microphone=(), payment=(), usb=()Providing features fully replaces the default set — list only the features you want in the header. Unknown feature names emit a console.warn (the spec evolves) but are still included; allowlist values that are neither a keyword (self, src, *) nor an origin-like URL also warn.
permittedCrossDomainPolicies
Sets X-Permitted-Cross-Domain-Policies.
permittedPolicies(string, default'none') — one ofnone,master-only,by-content-type,all.
referrerPolicy
Sets Referrer-Policy.
policy(string or string[], default'no-referrer') — one or more ofno-referrer,no-referrer-when-downgrade,origin,origin-when-cross-origin,same-origin,strict-origin,strict-origin-when-cross-origin,unsafe-url. When an array is given, values are joined with,for fallback handling.
reportingEndpoints
Sets Reporting-Endpoints, the Reporting API Level 2 header that maps named reporting endpoints to URLs. It is the modern successor to reportTo and is the form newer browsers prefer. Opt-in: when no reportingEndpoints option is provided, the header is not emitted.
Pass an object mapping each endpoint name to a URL:
- endpoint name (key) — a lowercase structured-field key: a lowercase letter or
*, then letters, digits, and_ - . *. This is the name CSP'sreport-todirective references. - URL (value, required) — an absolute
http:orhttps:URL. Values are parsed with the WHATWG URL parser and emitted in normalized form (e.g. a bare origin gains a trailing/); relative references and non-HTTP(S) schemes are rejected.
The object must contain at least one endpoint.
await server.register({
plugin: Aegis,
options: {
reportingEndpoints: {
default: 'https://example.com/reports',
csp: 'https://example.com/csp-reports'
}
}
});
// Reporting-Endpoints: default="https://example.com/reports", csp="https://example.com/csp-reports"Wiring CSP reports end-to-end
Route CSP violation reports to a named endpoint with the modern Reporting API: name the endpoint in reportingEndpoints, then point CSP's report-to directive at that name.
await server.register({
plugin: Aegis,
options: {
reportingEndpoints: { default: 'https://example.com/csp-reports' },
contentSecurityPolicy: {
directives: { reportTo: ['default'] }
}
}
});
// Content-Security-Policy: …; report-to default
// Reporting-Endpoints: default="https://example.com/csp-reports"For older browsers that still rely on the legacy Report-To header, additionally configure the reportTo middleware with a matching group name (reportTo: { group: 'default', … }) so both headers point at the same endpoint.
reportTo
Sets Report-To, the Reporting API header that registers named endpoint groups browsers can deliver reports to. Opt-in: when no reportTo option is provided, the header is not emitted.
Pass a single group object or an array of groups. Each group accepts:
group(string, default'default') — endpoint group name. Referenced from CSP viadirectives: { reportTo: '<group>' }.maxAge(integer, required) — non-negative seconds the browser should remember the group.endpoints(array, required) — non-empty list of{ url: string }objects.includeSubdomains(boolean, defaultfalse) — whentrue, the group also applies to subdomains.
Pair it with CSP's report-to directive to route CSP violation reports to the same endpoint:
await server.register({
plugin: Aegis,
options: {
reportTo: {
group: 'csp-endpoint',
maxAge: 10886400,
endpoints: [{ url: 'https://example.com/csp-reports' }],
includeSubdomains: true
},
contentSecurityPolicy: {
directives: { reportTo: 'csp-endpoint' }
}
}
});Multiple groups (e.g. one for CSP and a fallback default):
options: {
reportTo: [
{ group: 'csp-endpoint', maxAge: 10886400, endpoints: [{ url: 'https://example.com/csp' }] },
{ group: 'default', maxAge: 86400, endpoints: [{ url: 'https://example.com/default' }] }
]
}The Report-To header is being superseded by Reporting-Endpoints (Reporting API Level 2). Both still work in major browsers today; configure the modern header via the reportingEndpoints middleware.
xssFilter
Sets X-XSS-Protection: 0. No options. See the FAQ for the rationale.
Content Security Policy
CSP is the most involved header, so it gets its own section.
Defaults
With useDefaults: true (the default) the built-in directives are:
{
defaultSrc: ["'self'"],
baseUri: ["'self'"],
fontSrc: ["'self'", 'https:', 'data:'],
formAction: ["'self'"],
frameAncestors: ["'self'"],
imgSrc: ["'self'", 'data:'],
objectSrc: ["'none'"],
scriptSrc: ["'self'"],
scriptSrcAttr: ["'none'"],
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
upgradeInsecureRequests: []
}Which produces:
Content-Security-Policy: default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; upgrade-insecure-requestsCustom directives (merging)
When useDefaults: true, user directives are merged with defaults on a per-key replace basis. Providing scriptSrc fully replaces the default scriptSrc; it is not concatenated.
options: {
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", 'https://cdn.example.com'],
imgSrc: ["'self'", 'data:', 'https://images.example.com']
}
}
}All other default directives (defaultSrc, styleSrc, etc.) are kept as-is.
Opting out of defaults
Set useDefaults: false to emit only your directives:
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"]
}
}Report-only mode
contentSecurityPolicy: {
reportOnly: true,
directives: { reportUri: ['/csp-report'] }
}This switches the emitted header name to Content-Security-Policy-Report-Only.
Dynamic per-request directives
Any directive value may be a function (request) => string | string[], either as the whole value or as an item inside a mixed array. The function is invoked during onPreResponse with the current Hapi request, and its return is merged into the emitted policy. This enables per-request nonces, hashes, or any CSP variation without disabling the middleware.
await server.register({
plugin: Aegis,
options: {
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", (request) => `'nonce-${request.app.nonce}'`]
}
}
}
});Pair it with a small onRequest extension that stashes a fresh nonce on request.app and your templates will have a matching value to attach to inline scripts:
const Crypto = require('crypto');
server.ext('onRequest', (request, h) => {
request.app.nonce = Crypto.randomBytes(16).toString('base64');
return h.continue;
});Rules for the resolver:
- A function may return
stringorstring[]. Array returns are flattened into the surrounding directive list. - A function that returns
nullorundefinedis skipped (useful for conditional values). - All other return types throw
hapi-aegis: contentSecurityPolicy directive "<name>" function returned invalid value; expected string or string[]. - Resolved values flow through the normal validation path, so quote-keyword and unknown-directive warnings still fire on function outputs.
- Resolution runs for every response, including Boom errors, so the header is consistent on 500s.
Automatic nonce generation
Set generateNonces: true to have hapi-aegis mint a fresh base64 nonce per request and append 'nonce-<value>' to both script-src and style-src automatically. The nonce is exposed on request.plugins.aegis.nonce so handlers and templates can attach the matching nonce="..." attribute to inline <script> / <style> tags.
await server.register({
plugin: Aegis,
options: {
contentSecurityPolicy: { generateNonces: true }
}
});
server.route({
method: 'GET',
path: '/',
handler: (request) => `
<!doctype html>
<script nonce="${request.plugins.aegis.nonce}">/* inline init */</script>
`
});Notes:
- The nonce is generated in an
onPreAuthextension, so it is available to auth strategies, validators, and route handlers. - It is injected after any user-supplied directive values resolve, so user entries (including function-valued ones) are preserved.
- With
useDefaults: falseand no user-suppliedscriptSrc/styleSrc, the middleware emits those directives with only the nonce. - The switch can be toggled per route via
options.plugins.aegis.contentSecurityPolicy.generateNonces— handy for static-asset or API-only routes that don't need a nonce. - Prefer this to the manual
onRequest+ function-valued pattern above unless you need a custom nonce shape or a different injection point.
Naming and edge cases
- camelCase → kebab-case. Directive names are given in camelCase and converted automatically:
scriptSrcAttr→script-src-attr,upgradeInsecureRequests→upgrade-insecure-requests. - Empty-array directives. A directive whose value is
[]renders without a value — useful for flag-style directives likeupgradeInsecureRequests: []. - Unknown directives.
hapi-aegiswarns viaconsole.warnfor directives it doesn't recognise, but still emits them. The CSP spec evolves; this is a nudge, not an error. - Unquoted keywords. Values like
self,none, andunsafe-inlinemust be single-quoted ("'self'"). Bare usage triggers aconsole.warnso a missing quote doesn't silently weaken the policy.
Route-Level Configuration
Every middleware can be overridden per route via options.plugins.aegis. Route-level settings take precedence for the middlewares they mention; everything else falls back to the server-level configuration.
server.route({
method: 'GET',
path: '/api',
options: {
plugins: {
aegis: {
contentSecurityPolicy: false, // disable CSP for this route
frameguard: { action: 'deny' } // tighten X-Frame-Options here
}
}
},
handler: () => ({ ok: true })
});Common patterns:
- Disable a header for a specific endpoint. JSON APIs often don't need CSP — set
contentSecurityPolicy: falseon the route. - Relax CSP for a page that needs third-party scripts. Provide a different
directivesobject on that route only. - Tighten one route past the server default. For instance,
frameguard: { action: 'deny' }on an admin page while the rest of the site runsSAMEORIGIN.
Comparison with Helmet
If you're used to Helmet on Express, the Hapi API will feel familiar.
Express + Helmet
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(helmet());Hapi + hapi-aegis
const Hapi = require('@hapi/hapi');
const Aegis = require('hapi-aegis');
const server = Hapi.server();
await server.register(Aegis);Custom CSP looks nearly identical:
Express + Helmet
app.use(helmet({
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", 'https://cdn.example.com']
}
}
}));Hapi + hapi-aegis
await server.register({
plugin: Aegis,
options: {
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", 'https://cdn.example.com']
}
}
}
});See Also
For per-request CSP variations (nonces, hashes, anything that depends on the request), use the Dynamic per-request directives feature above — function-valued directives are resolved during onPreResponse with the Hapi request. If you'd prefer a dedicated plugin for that use case, blankie is an alternative; you can run it alongside hapi-aegis by setting contentSecurityPolicy: false in the hapi-aegis options and letting blankie handle CSP while hapi-aegis handles the other headers.
FAQ
Does this work with Boom error responses?
Yes. The plugin attaches an onPreResponse extension that detects Boom responses (response.isBoom) and applies headers to response.output.headers. A 400 or 500 response gets the same security headers as a 200.
Can I use this alongside other Hapi auth/validation plugins?
Yes. hapi-aegis only reads and writes response headers in an onPreResponse extension; it doesn't touch routing, authentication, validation, or the request lifecycle. Register it alongside @hapi/jwt, @hapi/bell, joi-based validation, and so on without conflict.
Why is X-XSS-Protection set to 0?
The legacy X-XSS-Protection filter has known bypasses and can be used as an XSS vector in itself. Modern browsers have removed or deprecated it. The safe default — matching Helmet — is to emit 0 so any residual browser behaviour is explicitly disabled, and to rely on Content-Security-Policy for XSS mitigation.
Is expectCt deprecated?
Yes. The Expect-CT header is deprecated by browsers. The middleware is included for legacy compatibility and sets max-age=0 by default, which is effectively a no-op. Disable it entirely with expectCt: false if you have no use for it.
Performance
hapi-aegis runs ~17 small middleware functions on each response via onPreResponse. The benchmarks/ directory contains autocannon-based scenarios that compare a bare Hapi server against hapi-aegis with defaults, with CSP disabled, and with only three middlewares enabled.
A local Node 22 run on a no-op route (handler returns the string "ok") measured ~24% lower req/s with defaults vs. bare (43.4k → 33.0k req/s after the 1.3.1 hot-path caching, down from ~36% pre-1.3.1) with p99 latency steady at 2–3 ms. Most of the remaining gap is bytes-on-wire — the default header set adds roughly 870 bytes to every response, so on no-op routes the network has more to push for each request. Routes returning real payloads see proportionally smaller relative overhead. See benchmarks/README.md for methodology and the full table. Run npm run bench locally, or trigger the manual Benchmarks GitHub Actions workflow to capture results across Node 18, 20, and 22.
Contributing
- Fork the repo and create a feature branch.
npm installto pull dev dependencies.- Make your change. Keep the middleware pattern (pure function,
{ header, value }out) and prefix all thrown errors withhapi-aegis:. - Add tests. Run
npm test— the suite must pass with ≥95% coverage. - Run
npm run lint. - Open a pull request. Use conventional commits for commit messages (e.g.
feat(hsts): …,fix(core): …).
Acknowledgements
hapi-aegis's option shapes, middleware scope, and sensible defaults are modeled after Helmet, the Express security-headers middleware. The implementation is independent — hapi's request lifecycle, Boom error handling, and route-level plugin configuration are all hapi-native — but the API similarity is intentional to make the plugin feel familiar to developers coming from Express.
License
MIT © 2026 Matt Rosenlund — see LICENSE.
