Secure headers and CSRFMiddleware · Hono

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 will learn

Read the theory for Secure headers and CSRF

All Middleware lessons

All Hono courses

loading types…

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

Secure headers and CSRF

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:

HeaderValueWhat it prevents
X-Frame-OptionsSAMEORIGINthe page being framed by another site (clickjacking)
X-Content-Type-Optionsnosniffa browser guessing a content type
Referrer-Policyno-referrerthe URL leaking in the Referer of outgoing links
Strict-Transport-Securitymax-age=15552000; includeSubDomainsplain HTTP after the first HTTPS visit
Cross-Origin-Opener-Policysame-originanother site keeping a handle to the window
Cross-Origin-Resource-Policysame-originanother site embedding the response
Origin-Agent-Cluster?1sharing a process with other origins
X-DNS-Prefetch-Controloffprefetching DNS for links
X-Download-Optionsnoopenold Internet Explorer opening downloads in place
X-Permitted-Cross-Domain-PoliciesnoneFlash-era cross-domain policy files
X-XSS-Protection0the 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.

  1. Add secureHeaders() with X-Frame-Options set to DENY, Strict-Transport-Security set to max-age=31536000; includeSubDomains, and a content security policy of default-src 'self'. Every other header keeps its default.
  2. Only forms from https://app.example may post: refuse the rest with csrf().

Send GET /cats from the request bar and count the headers.

When it fails

  • x-frame-options is SAMEORIGIN: the option was not passed, and the default applies.
  • content-security-policy is missing: it has no default; pass the object.
  • The form from https://app.example is refused: origin was written with a path or a trailing slash. An origin is scheme, host and port only.
  • The JSON post from https://evil.example is 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, false drops one, contentSecurityPolicy takes an object of directives.
  • csrf() refuses form-typed writes whose Origin is not allowed, with 403 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.