Secure headers and CSRF
Tell browsers how to treat the API's responses with secureHeaders(), tune two of its headers and add a content security policy, then stop cross-site forms from posting with csrf().
What you'll learn
- Add the browser security headers Hono sets by default and know what each one prevents
- Override one header, turn one off, and write a Content-Security-Policy as an object
- Refuse cross-site form submissions with csrf(), and understand why a JSON request is not its concern
Most of what a browser does to protect a user is switched on by headers the server sends, and a server that sends none gets the permissive defaults: pages may be framed by any site, responses may be sniffed into a different content type, the referrer leaks to whoever is linked, and the first visit goes over plain HTTP. The list is long and the values are easy to get wrong, which is why every framework ships a middleware that sets the sensible set at once. Hono's is secureHeaders. Its neighbour csrf closes a different hole: an HTML form on another site posting to your API with the user's cookies.
secureHeaders()
import { secureHeaders } from 'hono/secure-headers';
app.use(secureHeaders());
With no options, every response gets eleven headers:
| Header | Value | What it prevents |
|---|---|---|
X-Frame-Options | SAMEORIGIN | the page being framed by another site (clickjacking) |
X-Content-Type-Options | nosniff | a browser guessing a content type |
Referrer-Policy | no-referrer | the URL leaking in the Referer of outgoing links |
Strict-Transport-Security | max-age=15552000; includeSubDomains | plain HTTP after the first HTTPS visit |
Cross-Origin-Opener-Policy | same-origin | another site keeping a handle to the window |
Cross-Origin-Resource-Policy | same-origin | another site embedding the response |
Origin-Agent-Cluster | ?1 | sharing a process with other origins |
X-DNS-Prefetch-Control | off | prefetching DNS for links |
X-Download-Options | noopen | old Internet Explorer opening downloads in place |
X-Permitted-Cross-Domain-Policies | none | Flash-era cross-domain policy files |
X-XSS-Protection | 0 | the broken filter of old browsers |
Each option is named after its header in camel case. A string replaces the value, false drops the header, and contentSecurityPolicy, off by default because no generic value fits an app, takes an object whose keys are the directives:
app.use(
secureHeaders({
xFrameOptions: 'DENY',
contentSecurityPolicy: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://cdn.example'],
},
}),
);
That renders default-src 'self'; script-src 'self' https://cdn.example. The policy is the one header worth reading about at length; the middleware only spells it.
csrf()
A page on https://evil.example can contain <form method="post" action="https://api.example/feedback"> and submit it, and the browser sends the user's cookies for api.example along. CORS does not stop it, because a form post is a "simple" request that needs no preflight. What identifies it is the Origin header the browser attaches, and the content types a form can produce: application/x-www-form-urlencoded, multipart/form-data, text/plain. csrf() checks exactly those:
import { csrf } from 'hono/csrf';
app.use(csrf());
app.use(csrf({ origin: 'https://app.example' }));
app.use(csrf({ origin: ['https://app.example', 'https://admin.example'] }));
A POST, PUT, PATCH or DELETE with a form content type whose Origin is not the request's own origin, or not in the origin option when one is given, is refused with 403 Forbidden. A request with no Origin at all is refused too, unless the browser's Sec-Fetch-Site: same-origin vouches for it. A JSON request passes untouched: a form cannot produce it, a script that could is subject to CORS, and so it is the previous lesson's concern, not this one's. Note that the middleware knows nothing about tokens hidden in forms; it relies on what browsers send today, which is the recommended approach.
Where it sits
Both are app.use() middleware above the routes. secureHeaders adds headers after next(), so it belongs on every response, including errors; csrf refuses before next(), so it belongs on every route a form could reach, which in practice is all of them.
Your task
The API sends no security headers and accepts any form.
- Add
secureHeaders()withX-Frame-Optionsset toDENY,Strict-Transport-Securityset tomax-age=31536000; includeSubDomains, and a content security policy ofdefault-src 'self'. Every other header keeps its default. - Only forms from
https://app.examplemay post: refuse the rest withcsrf().
Send GET /cats from the request bar and count the headers.
When it fails
x-frame-optionsisSAMEORIGIN: the option was not passed, and the default applies.content-security-policyis missing: it has no default; pass the object.- The form from
https://app.exampleis refused:originwas written with a path or a trailing slash. An origin is scheme, host and port only. - The JSON post from
https://evil.exampleis refused:csrf()is not what refused it; look for a guard from an earlier lesson.csrf()never checks JSON.
Remember
secureHeaders()sets eleven headers; a string overrides one,falsedrops one,contentSecurityPolicytakes an object of directives.csrf()refuses form-typed writes whoseOriginis not allowed, with403 Forbidden.- JSON is CORS's problem, not CSRF's.
- Both go above the routes, on everything.
Stuck? Show a hint
secureHeaders({ xFrameOptions: 'DENY', strictTransportSecurity: '…', contentSecurityPolicy: { defaultSrc: ["'self'"] } }) sets everything else to its default. csrf({ origin: 'https://app.example' }) allows form posts from that origin only. Both are app.use() middleware above the routes.