Skip to content

@tetsujs/secure-headers

@tetsujs/secure-headers sets the response headers that tell a browser to turn off content sniffing, framing and referrer leaks, and to use HTTPS only. It is one beforeResponse hook, and its defaults are safe for a JSON API.

Terminal window
bun add @tetsujs/secure-headers
import { secureHeaders } from "@tetsujs/secure-headers";
const secure = secureHeaders();
createApp({ hooks: { beforeResponse: [secure] }, routes });

Mount it on the application, so it covers every response, errors and 404s included. Sent by default:

Header Value
x-content-type-options nosniff the browser does not guess a content type
x-frame-options DENY the response cannot be framed
referrer-policy no-referrer URLs with identifiers do not leak to other sites
strict-transport-security max-age=15552000 HTTPS only, for 180 days

The headers are set with set, so they replace a header of the same name that the handler set.

Option Default
hsts { maxAge: 15552000 } maxAge, includeSubDomains, preload; false turns it off
frameOptions "DENY" "SAMEORIGIN", or false
referrerPolicy "no-referrer" any policy, or false
noSniff true false turns it off
contentSecurityPolicy not sent a policy string; apiPolicy is ready-made

HSTS is sent on every response. Browsers ignore it over plain HTTP, so it works the same behind any proxy.

includeSubDomains and preload are off by default. The first breaks any subdomain still served over plain HTTP, for as long as maxAge says. The second takes months and a browser release to undo. Turn them on when you know they are safe:

const secure = secureHeaders({
hsts?: false | HstsOptions | undefined

strict-transport-security, or false to leave it off.

Sent on every response, including those that arrived over plain HTTP, where the browser is required to ignore it — so this needs to know nothing about proxies or protocols to be correct.

hsts
: {
maxAge?: number | undefined

Lifetime in seconds. 180 days by default.

Long enough to matter and short enough to be wrong about: the browser refuses plain HTTP to this host until it expires, and there is no way to reach the browsers that already heard it.

maxAge
: 63_072_000,
includeSubDomains?: boolean | undefined

Whether every subdomain is covered too. Off by default.

A subdomain served over HTTP — a legacy box, a status page, a certificate-less internal tool — stops being reachable the moment one request to the parent carries this, for as long as maxAge says. It is the right setting for most deployments and the wrong default for any of them.

includeSubDomains
: true } });

preload without includeSubDomains, or with a maxAge under one year (31,536,000 seconds), makes secureHeaders() throw, because the browsers’ preload list would reject it.

No policy is sent by default: a wrong policy breaks pages, and the failure only shows in the browser of whoever loads them. For an API that only answers JSON, apiPolicy denies everything:

const secure = secureHeaders({
contentSecurityPolicy?: string | false | undefined

content-security-policy. Off by default, and alone in that.

A policy is a statement about a document, and this framework answers with documents only where an application chose to. A default strict enough to be worth having would break the first page anyone serves, including this repository's own documentation page — docsPage() loads its renderer from a CDN and carries an inline script, so default-src 'none' would blank it. A default loose enough not to break it would protect nothing.

What breaks is also the wrong kind of breakage to inflict silently: a policy is enforced in the browser, so the failure is a blank page and a console message on someone else's machine, not an error on the server.

An API that answers only JSON should still have one, and

  • apiPolicy

is it.

contentSecurityPolicy
: apiPolicy });
// default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'

If the same application also serves a page, such as the docs page of @tetsujs/openapi, remove the policy for that route:

const
const docsPage: "/docs"
docsPage
= "/docs";
const allowDocs = hook.beforeResponse((ctx) => {
if (ctx.
route?: RouteInfo | undefined

The route this request matched, or nothing when none did.

Optional because a 404, a 405 and a preflight run the pipeline too, and there is no route behind them — the same reason req.cookies is optional. In a route's own context the field is not optional: a hook mounted on a route, and its handler, always have one.

It is the field that makes an observer able to name the endpoint rather than the URL: ctx.route.path is /users/:id, where new URL(ctx.req.url).pathname is /users/42. The difference is cosmetic in a log line and structural in a metric, where a label built from the second one grows a new series per identifier.

route
?.
path: string | undefined

The path as the route declared it, with group prefixes joined and :params left as they are — /api/users/:id, never /api/users/42.

path
===
const docsPage: "/docs"
docsPage
) ctx.
out: Outgoing

Response parameters for the serialized handler result.

out
.
headers: Headers

Headers applied to the outgoing response, whatever produced it — set-cookie values are appended, any other name overwrites.

A live standard Headers, created on first access. It is never assigned, only mutated — set() to own a header, append() to add to it — so two hooks writing headers compose instead of overwriting each other's whole set.

headers
.delete("content-security-policy");
});
const secure = secureHeaders({
contentSecurityPolicy?: string | false | undefined

content-security-policy. Off by default, and alone in that.

A policy is a statement about a document, and this framework answers with documents only where an application chose to. A default strict enough to be worth having would break the first page anyone serves, including this repository's own documentation page — docsPage() loads its renderer from a CDN and carries an inline script, so default-src 'none' would blank it. A default loose enough not to break it would protect nothing.

What breaks is also the wrong kind of breakage to inflict silently: a policy is enforced in the browser, so the failure is a blank page and a console message on someone else's machine, not an error on the server.

An API that answers only JSON should still have one, and

  • apiPolicy

is it.

contentSecurityPolicy
: apiPolicy });
createApp({
hooks: { beforeResponse: [secure, allowDocs] },
routes: [docs({
info: DocumentInfo

Title, version and the rest of the document's info block.

info
,
uiPath?: "/docs" | undefined

Where the page is served. Defaults to /docs.

uiPath
:
const docsPage: "/docs"
docsPage
}), api()],
});

ctx.route.path is the route’s full path: mounted in a group under /api, the page is /api/docs.

A route’s own beforeResponse hook runs after the application’s, so it can change a header for that route. Setting the header in the handler does not work: the handler runs first, and the application’s hook overwrites it.

const allowFraming = hook.beforeResponse((ctx) => {
ctx.
out: Outgoing

Response parameters for the serialized handler result.

out
.
headers: Headers

Headers applied to the outgoing response, whatever produced it — set-cookie values are appended, any other name overwrites.

A live standard Headers, created on first access. It is never assigned, only mutated — set() to own a header, append() to add to it — so two hooks writing headers compose instead of overwriting each other's whole set.

headers
.set("x-frame-options", "SAMEORIGIN");
const
const policy: string | null
policy
= ctx.
out: Outgoing

Response parameters for the serialized handler result.

out
.
headers: Headers

Headers applied to the outgoing response, whatever produced it — set-cookie values are appended, any other name overwrites.

A live standard Headers, created on first access. It is never assigned, only mutated — set() to own a header, append() to add to it — so two hooks writing headers compose instead of overwriting each other's whole set.

headers
.get("content-security-policy");
if (
const policy: string | null
policy
) {
ctx.
out: Outgoing

Response parameters for the serialized handler result.

out
.
headers: Headers

Headers applied to the outgoing response, whatever produced it — set-cookie values are appended, any other name overwrites.

A live standard Headers, created on first access. It is never assigned, only mutated — set() to own a header, append() to add to it — so two hooks writing headers compose instead of overwriting each other's whole set.

headers
.set(
"content-security-policy",
const policy: string
policy
.replace(/frame-ancestors [^;]*/, "frame-ancestors 'self'"),
);
}
});
const embeddable = route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/embeddable"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/embeddable",
hooks: { beforeResponse: [allowFraming] },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Response

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: () => page(),
});

When the policy has frame-ancestors, as apiPolicy does, browsers follow it and ignore x-frame-options, so allowing a frame means changing both.

  • The package also exports the types SecureHeadersOptions, HstsOptions and SecureHeadersHook. Type a hook with ReturnType<typeof secureHeaders> rather than AnyHook, which erases the slot the hook belongs to.