Middleware and security
Security headers, the deny-by-default frame guard, trusted origins, CSRF, and the production hardening model.
@ilha/router hardens the server-island endpoint by default. This page is the full guard and origin model behind Server islands.
Security headers
Both respond() and the low-level httpResponse() emit sensible security headers by default:
| Header | Value |
|---|---|
Content-Type |
text/html |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
no-referrer |
Cache-Control |
no-store |
A header is added only when the caller has not already set it, so you can override any default by passing headers.
// Default: nosniff, no-referrer, no-store (see table above).
const const res: Responseres = function httpResponse(body: string | null, options?: HttpResponseOptions): ResponseBuild an HTTP `Response` for SSR output with sensible security headers:
`Content-Type: text/html`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy`, `Cache-Control: no-store`, and an optional CSP. This is
a low-level helper — prefer
{@link
RouterBuilder.respond
}
for the full
render+head+headers pipeline.httpResponse("<h1>Hi</h1>");
// Conservative default CSP via a nonce — pass the same nonce to <script nonce=…>.
const const withCsp: ResponsewithCsp = function httpResponse(body: string | null, options?: HttpResponseOptions): ResponseBuild an HTTP `Response` for SSR output with sensible security headers:
`Content-Type: text/html`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy`, `Cache-Control: no-store`, and an optional CSP. This is
a low-level helper — prefer
{@link
RouterBuilder.respond
}
for the full
render+head+headers pipeline.httpResponse(const html: "<h1>Hi</h1>"html, { HttpResponseOptions.cspNonce?: string | undefinedCSP nonce for inline scripts. When set (and `contentSecurityPolicy` is
not), a conservative default CSP is emitted with `'nonce-${nonce}'` for
`script-src` — pass the same nonce to head `<script nonce=…>` tags.cspNonce: const nonce: "abc123"nonce });
// Full CSP string wins over the nonce-derived default.
const const locked: Responselocked = function httpResponse(body: string | null, options?: HttpResponseOptions): ResponseBuild an HTTP `Response` for SSR output with sensible security headers:
`Content-Type: text/html`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy`, `Cache-Control: no-store`, and an optional CSP. This is
a low-level helper — prefer
{@link
RouterBuilder.respond
}
for the full
render+head+headers pipeline.httpResponse(const html: "<h1>Hi</h1>"html, {
HttpResponseOptions.contentSecurityPolicy?: string | undefinedFull `Content-Security-Policy` string; overrides the nonce-derived default.contentSecurityPolicy: "default-src 'self'",
});
| Option | Behavior |
|---|---|
status |
Response status (default 200). |
headers |
Extra headers, merged over defaults. |
cspNonce |
Emit a conservative default CSP with 'nonce-${nonce}' for script-src. |
contentSecurityPolicy |
Full CSP string; overrides the nonce-derived default. |
Prefer respond() when you are rendering a route — it runs the full render + head + headers pipeline. Reach for httpResponse() when you already have the HTML and only need a correctly-headered Response.
Deny-by-default frame endpoint
In production, the internal frame endpoint is denied unless you install a guard:
| Endpoint | Serves | Method | Default when unguarded |
|---|---|---|---|
/__ilha/frame |
Re-render of a server island or server page | POST | Denied (403) |
An unguarded frame endpoint would let anyone re-render any server island by id — if an island’s state holds private data you must install a session check. The dev middleware stays permissive unless you register a guard, so local iteration works out of the box.
Installing guards
Register guards once at server startup for production, via the shared registry:
// Gates /__ilha/frame in production; without this the endpoint returns 403.
function setFrameGuard(guard: FrameGuard): voidInstall a guard consulted by every `/__ilha/frame` request (dev middleware
and the production `@ilha/router/ssr` handler share this slot — both read
it from `globalThis`). Return a `Response` to reject; return nothing to
allow. Island state is world-readable through frames unless you gate them,
so apps serving private data should install a session check here.setFrameGuard((request: Requestrequest) => {
if (!function isSignedIn(_request: Request): booleanisSignedIn(request: Requestrequest))
return new var Response: new (body?: BodyInit | null, init?: ResponseInit) => ResponseThe **`Response`** interface of the Fetch API represents the response to a request.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Response)Response("Unauthorized", { ResponseInit.status?: number | undefinedstatus: 401 });
});
// Optional: explicit trusted origins and a CSRF verifier for the frame POST.
function setFrameAuth(policy: FrameAuthPolicy): voidInstall the frame-authorization policy consumed by the production
`@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
the dev middleware (via `IlhaPagesOptions`).setFrameAuth({
FrameAuthPolicy.trustedOrigins?: string[] | undefinedExplicit trusted origins (e.g. `"https://app.example.com"`). When set,
origin checks accept only these; otherwise the check compares the `Origin`
header against `https://{host}` / `http://{host}`.trustedOrigins: ["https://app.example.com"],
// Verify a per-session token issued by your server (double-submit cookie or
// synchronizer token), compared in constant time. Never use a build-time
// environment variable or literal string — anything shipped to the browser
// is public and cannot be a CSRF secret.
FrameAuthPolicy.csrf?: ((request: Request) => boolean | Promise<boolean>) | undefinedOptional CSRF verifier for the state-changing frame POST. Receives the
original `Request`; returning falsy rejects the request. Use this for
server-to-server frame callers that have no browser `Origin`.csrf: (request: Requestrequest) => request: Requestrequest.Request.headers: HeadersThe **`headers`** read-only property of the Request interface contains the Headers object associated with the request.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers)headers.Headers.get(name: string): string | nullThe **`get()`** method of the Headers interface returns a byte string of all the values of a header within a Headers object with a given name. If the requested header doesn't exist in the Headers object, it returns null.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get)get("x-csrf") === const expectedToken: string | undefinedexpectedToken,
});
In dev, configure the guard through the plugin options instead — pass frameGuard to the pages plugin.
setFrameAuth
Tuning the frame-auth policy:
| Option | Type | Behavior |
|---|---|---|
defaultAction |
"open" | "deny" |
"deny" is the default. "open" opts out of deny-by-default (not recommended). |
trustedOrigins |
string[] |
Explicit origins to accept for frame origin checks. Otherwise the Origin header is compared against the request’s own Host. |
csrf |
(request) => boolean |
Extra verifier for the frame POST — covers non-browser callers that send no Origin header. |
What is forwarded
The middleware forwards only cookie, authorization, and user-agent headers to the scoped frame render. It deliberately does not forward a client-supplied x-forwarded-for header — it is spoofable and must not be trusted for IP checks. If you relied on it for IP-based decisions, move that check to a trusted proxy layer instead.
Validation and status clamping
- Frame bodies are streamed with a hard 16 KiB cap; oversized bodies return
413. - Frame paths are validated and fail closed — an invalid path returns
400instead of falling back to/. - Failures are typed: the frame render returns a
FrameErrorresult, andredirect()coerces invalid statuses to302;error()coerces invalid statuses to500(silently). - Render failures surface as
"frame failed"— error details are never leaked to clients in production.
Related
| Topic | Guide |
|---|---|
| Server islands and frames | Server islands |
| Error handling | Error boundaries |
| Guards via the pages plugin | File-system routing |
| Production wiring | Deployment |