Security Headers, CORS & CSRF
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2), Node.js 20.19+ or 22.12+, TypeScript 6, Express 5 and
Fastify 5, and was checked on 2026-10-02. The built-in methods come from @nestjs/common and @nestjs/core 12.1; the
third-party packages used alongside them are helmet 8, @fastify/helmet, cookie-parser 1.4, csrf-csrf,
@fastify/cookie 11 and @fastify/csrf-protection. Every snippet builds with nest build and tsc --noEmit on NestJS
12.1.2, and works on both the Express and the Fastify adapter; the headers and status codes shown are what the snippets
return.
A browser enforces a set of rules that an HTTP server can switch on or relax with response headers: which scripts a page may
run, whether it may be framed, which other origins may call its API, and whether a request that another site triggered is
accepted. They are three separate mechanisms with one thing in common: they protect the browser’s user, and a client
that is not a browser ignores them. This page covers all three in NestJS. Before 12.1 each one meant a third-party package;
12.1 added app.useSecurityHeaders() and app.enableCsrfProtection() to the application object, with identical behaviour on
Express and Fastify. For the theory of CORS itself, see CORS. For cookies, which CSRF attacks abuse, see
Cookies & Sessions.
The scenario is the Bookshelf API of the other pages, a books controller with GET, POST, PUT and DELETE
handlers and a webhooks/payments endpoint that a payment provider calls.
@Controller('books')
export class BooksController {
@Get()
findAll() {
return [{ id: 1, title: 'Dune' }];
}
@Post()
create(@Body() body: { title: string }) {
return { id: 2, title: body.title };
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id') _id: string) {}
}
Where the three mechanisms sit
All three are registered at the application level, before routing, so they apply to every response, including 404 pages
and error responses:
| Mechanism | Built into Nest | Third-party alternative |
|---|---|---|
Security headers |
|
|
CORS |
|
the same packages used directly |
CSRF |
|
|
Because they run in a framework-level middleware (Express) or onRequest hook (Fastify), they run before Nest middleware,
body parsing, guards, interceptors and pipes. The consequence is that a controller-scoped exception filter, guard or
interceptor never sees a request that these layers reject. The order of the rest of the pipeline is in
Request Lifecycle and Middleware.
Security headers with useSecurityHeaders()
app.useSecurityHeaders() sets the headers that Helmet 8 sets by default, with the same values, and removes X-Powered-By.
It needs no package and behaves the same on both adapters.
export function defaultHeaders(app: INestApplication) {
app.useSecurityHeaders();
}
Requesting GET /books against the Express and the Fastify application returned the same headers, apart from the
adapter-specific etag:
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-requests
cross-origin-opener-policy: same-origin
cross-origin-resource-policy: same-origin
origin-agent-cluster: ?1
referrer-policy: no-referrer
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-dns-prefetch-control: off
x-download-options: noopen
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-xss-protection: 0
Cross-Origin-Embedder-Policy is not sent by default. A request to a route that does not exist (GET /nope) returned 404
with the same headers, and X-Powered-By was absent.
Rules to remember
-
Call it before
app.init()orapp.listen(). Calling it afterwards, or calling it twice, throwsapp.useSecurityHeaders() must be called before app.init() / app.listen(). -
Options are validated immediately, so a bad configuration fails at startup rather than at the first request.
-
The headers are written by a hook registered at the point of the call. Middleware that you registered with
app.use()earlier runs first, and a response such a middleware sends itself carries no headers. Call it right afterNestFactory.create(). -
useSecurityHeaders()andenableCsrfProtection()share one request hook, so they can be called in either order and the headers are always written before the CSRF check, which means a rejected request carries them too.
Configuring individual headers
Each option controls one header: false leaves it out, true sends its default, and an object configures it. The option
names are not Helmet’s legacy ones.
export function tunedHeaders(app: INestApplication) {
app.useSecurityHeaders({
xFrameOptions: { action: 'deny' },
strictTransportSecurity: { maxAge: 63072000, preload: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", 'https://cdn.example.com'],
connectSrc: ["'self'", 'wss://realtime.example.com'],
upgradeInsecureRequests: null,
},
},
});
}
The response headers are:
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' https://cdn.example.com;script-src-attr 'none';style-src 'self' https: 'unsafe-inline';connect-src 'self' wss://realtime.example.com
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
The header options and their object forms are listed in this table:
| Option | Properties |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
boolean only |
Unknown options and invalid values throw at startup. Passing Helmet’s legacy name hsts produced:
Invalid security headers configuration: unknown option "hsts" (helmet's legacy name, use "strictTransportSecurity").
The renamed options are dnsPrefetchControl to xDnsPrefetchControl, frameguard to xFrameOptions, hidePoweredBy to
xPoweredBy, hsts to strictTransportSecurity, ieNoOpen to xDownloadOptions, noSniff to xContentTypeOptions,
permittedCrossDomainPolicies to xPermittedCrossDomainPolicies and xssFilter to xXssProtection.
Overriding a header on one route
A handler can override a header that the application sets, for example to let other sites embed an image, with @Header()
(see Controllers & Routing).
@Get('covers/:id')
@Header('Cross-Origin-Resource-Policy', 'cross-origin')
findCover(@Param('id') id: string) {
return { cover: id };
}
Both adapters returned cross-origin-resource-policy: cross-origin for GET /books/covers/1, while every other route kept
same-origin. On Fastify, reply.header() overrides as well, but reply.removeHeader() cannot remove a header set by
useSecurityHeaders().
Content-Security-Policy
The default policy is Helmet 8’s, sent on one line. The rules for directives:
-
names are camelCase (
scriptSrc) or kebab-case ('script-src'); -
a directive you set replaces the default directive of the same name, so repeat
'self'if you want to keep it, while directives you do not mention stay in the policy; -
nullorfalseremoves a directive, including a default one (upgradeInsecureRequests: nullabove); -
keywords, nonces and hashes must be single-quoted. An unquoted
selfis rejected at startup withcontains "self": keywords, nonces and hashes must be single-quoted (e.g. "'self'"); -
values may contain only printable ASCII and neither
;nor,, so a value cannot smuggle in another directive; -
useDefaults: falsesends only your directives, which then need adefaultSrc(ordefaultSrc: nullon purpose).
A policy is easy to get wrong in a way that breaks pages. Set reportOnly to true first: Nest then sends
Content-Security-Policy-Report-Only, browsers report violations instead of blocking them, and no
Content-Security-Policy header is sent.
export function reportOnlyCsp(app: INestApplication) {
app.useSecurityHeaders({ contentSecurityPolicy: { reportOnly: true } });
}
A test shows content-security-policy absent and content-security-policy-report-only carrying the default policy. Add a
report-to or report-uri directive to receive the reports. To leave the header out entirely, set contentSecurityPolicy
to false.
Swagger UI and GraphiQL
The policy applies to every response, including the HTML pages of API tools, and that is where it bites:
-
Swagger UI from
@nestjs/swaggerserves its scripts and stylesheets from your own application, so it works with the default policy. Scripts added with thecustomJsoption need their host inscriptSrc, and inline code fromcustomJsStrneeds'unsafe-inline'. The defaultupgrade-insecure-requestsmakes browsers fetch assets over HTTPS, so Swagger UI served over plain HTTP outside local development, for example on a staging server reached by IP address, fails to load. Serve it over HTTPS or setupgradeInsecureRequests: null. -
GraphiQL, as served by the Apollo driver, loads React and GraphiQL from
unpkg.comand starts with an inline script. It is enabled by default only whenNODE_ENVis notproduction, so relax the policy only there. -
Apollo Sandbox (the
ApolloServerPluginLandingPageLocalDefault()landing page) loads scripts, images and a manifest from Apollo’s CDN and embeds the sandbox in a frame, which needsscriptSrc,imgSrc,manifestSrcandframeSrcentries for those hosts.
export function developmentCsp(app: INestApplication, isProduction: boolean) {
app.useSecurityHeaders({
contentSecurityPolicy: isProduction
? true
: {
directives: {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
},
},
});
}
With isProduction false the resulting policy is script-src 'self' 'unsafe-inline' https://unpkg.com and every other
directive stays at its default. 'unsafe-inline' in script-src gives up most of the protection CSP offers against
cross-site scripting for every page the application serves, so keep it out of production. The OpenAPI and GraphQL setup
itself is covered on their own pages.
Using Helmet directly
Helmet remains valid, and it is what every application on NestJS 11 or earlier uses. Since the option names of
useSecurityHeaders() are Helmet’s, a Helmet configuration mostly carries over when you switch, with two exceptions: the
legacy names above are rejected, and CSP directive values must be strings, lists of strings, booleans or null, so the
functions Helmet accepts to generate per-request nonces are not supported by the built-in method. If you need nonces, keep
Helmet.
Register Helmet before other app.use() calls or setup functions that may call it, since the platform applies middleware in
registration order and Helmet registered after a route does not cover that route.
import { NestExpressApplication } from '@nestjs/platform-express';
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import helmet from 'helmet';
import fastifyHelmet from '@fastify/helmet';
export function expressHelmet(app: NestExpressApplication) {
app.use(helmet());
}
export async function fastifyHelmetPlugin(app: NestFastifyApplication) {
await app.register(fastifyHelmet);
}
npm i helmet # Express
npm i @fastify/helmet # Fastify
On Fastify, @fastify/helmet is a Fastify plugin, not a middleware, so it is registered with app.register(), which
returns a promise. With both packages the headers match the built-in defaults (same content-security-policy and
strict-transport-security).
CORS
CORS lets a page on one origin call an API on another. A browser sends a preflight OPTIONS request with
Access-Control-Request-Method before a non-simple request, and the server answers with Access-Control-Allow-* headers
that tell the browser whether to proceed. Enabling CORS does not stop a non-browser client from calling the API; see
CORS for the model.
Nest delegates to the Express cors package or to @fastify/cors, depending on the adapter. Two equivalent ways to turn it on:
const app = await NestFactory.create(AppModule);
app.enableCors();
// or, through the create() options
const app = await NestFactory.create(AppModule, { cors: true });
enableCors() and the cors option take either the configuration object of the underlying package or a callback that
computes it per request.
export function configureCors(app: INestApplication) {
const config = app.get(ConfigService);
const origins = (config.get<string>('CORS_ORIGINS') ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
app.enableCors({
origin: origins,
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
credentials: true,
maxAge: 600,
});
}
With CORS_ORIGINS set to two origins, a preflight OPTIONS /books/1 from the first one with
Access-Control-Request-Method: PUT was answered like this on both adapters:
204 No Content
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET,HEAD,PUT,PATCH,POST,DELETE
access-control-allow-credentials: true
access-control-max-age: 600
vary: Origin, Access-Control-Request-Headers
(Fastify separates the methods with , `.) A request from an origin that is not in the list received no
`access-control-allow-origin header, so the browser blocks the response. The application still ran the handler and
returned 200, which is a reminder that CORS is enforced by the browser, not by the server.
Source: NestJS: CORS, "Getting started"
The adapters do not share defaults
enableCors() with no options is not the same on both platforms. The same preflight for PUT against app.enableCors()
returned:
Express (cors) |
Fastify (@fastify/cors) |
|
|---|---|---|
|
|
|
|
|
|
The same applied with { cors: true }. On Fastify, a cross-origin PUT, PATCH or DELETE is therefore rejected at the
preflight unless you list the method, while the same code works on Express. Always set methods explicitly when your API is
called from another origin with a method outside the safelist; both packages read it.
Pitfalls
-
origin: '*'(the default) cannot be combined withcredentials: trueby browsers; list the origins instead. -
An allow-list read from configuration should hold exact origins (
https://app.example.com), not host names. -
CORS and CSRF are independent: origins allowed by CORS are not trusted by the CSRF protection below.
-
CORS configured through
NestFactory.create(…, { cors })is registered afterenableCsrfProtection(), so a CSRF403does not carry CORS headers and a cross-origin client sees an opaque failure rather than the403body.
CSRF protection
Cross-site request forgery makes a victim’s browser send a state-changing request to your application from another site; the browser attaches the victim’s cookies, and the server cannot tell the request from a deliberate one. NestJS 12.1 ships protection that needs no token, cookie, session or client change. It relies on headers that browsers send themselves.
How the built-in check decides
app.enableCsrfProtection() rejects state-changing requests that the browser reports as cross-origin, following the
algorithm of Go 1.25’s CrossOriginProtection:
-
GET,HEADandOPTIONSare always allowed. -
If
Sec-Fetch-Siteis present,same-originandnone(a navigation the user started) are allowed; every other value, includingsame-site, is rejected. -
If neither
Sec-Fetch-SitenorOriginis present, the request is allowed: it does not come from a browser. -
Otherwise the host of
Originmust match the request’sHostheader (:authorityon HTTP/2), ignoring case and the default port.X-Forwarded-Hostis ignored.
A request that would be rejected is still allowed when it matches an excluded route or when its Origin is a trusted origin.
export function configureCsrf(app: INestApplication) {
const config = app.get(ConfigService);
const trusted = (config.get<string>('TRUSTED_ORIGINS') ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
app.setGlobalPrefix('api');
app.useSecurityHeaders();
app.enableCsrfProtection({
trustedOrigins: trusted,
exclude: [{ path: 'webhooks/payments', method: RequestMethod.POST }],
});
}
With TRUSTED_ORIGINS=https://admin.example.com, the results were identical on Express and Fastify:
| Request | Status |
|---|---|
|
201 |
|
201 |
|
403 |
|
403 |
|
201 |
|
403 |
|
201 |
|
200 |
|
403 |
|
200 |
|
403 |
The rejection is a ForbiddenException:
{
"message": "Cross-origin request detected from Sec-Fetch-Site header",
"error": "Forbidden",
"statusCode": 403
}
and without Sec-Fetch-Site the message reads Cross-origin request detected, and/or browser is out of date: Sec-Fetch-Site
is missing, and Origin does not match Host. The x-frame-options header and the rest were present on the 403, because the
security headers are written first. Global exception filters apply to the rejection (see
Exception Filters & Error Handling); controller-scoped ones do
not, because routing has not run.
Options, trusted origins and exclusions
The options object accepts only trustedOrigins and exclude; anything else throws at startup. Passing the misspelled
trustedOrigin produced:
Invalid CSRF protection options: unknown option "trustedOrigin" (expected "trustedOrigins", "exclude").
-
trustedOriginslists origins that may send cross-origin, state-changing requests, such as an admin panel on a sibling subdomain. Each entry is a serialized originscheme://host[:port]: no path (not even/), query, fragment, credentials or wildcards. Entries are normalized like theOriginheader (lower case, default port dropped), and a request is exempt only when itsOriginequals one entry exactly. A frontend allowed by CORS is not trusted automatically; if it sends state-changing requests with credentials, list it here too. -
excludelists routes that skip the check, as a path (all methods) or aRouteInfoobject withpath,methodand optionalversion, declared without the global prefix. Use it for a sender that sets a foreignOrigin, such as a webhook, or an identity provider that returns the user with a cross-site formPOST(SAML, or OAuth’sform_postmode). Exclusions are resolved at initialization, adding the global prefix (see Versioning & Global Prefix) and, for URI versioning, the version segment of the exclusion’sversion; the default version is not applied. Matching is exact and fails closed: it is case-sensitive, a trailing slash does not match unless you writewebhooks/payments{/}, and paths containing//,.or..segments,;,#,\or encoded.,/,\never match. On Fastify, exclusions are matched beforerewriteUrlruns. -
excludecan also be a predicate that receives the raw platform request and returnstrueto skip the check. None of the matching rules apply to it, so a predicate that testsrequest.url.startsWith('/public/')also skips/public/../account. Prefer a route list.
A webhook endpoint should still authenticate the sender, typically with a signature; see Webhooks. Excluding it from CSRF only removes an origin check that would reject a legitimate sender.
Limitations
-
It is not a token scheme: it relies on
Sec-Fetch-Site, which browsers send only to secure origins (HTTPS orlocalhost), or onOrigin. -
GET,HEADandOPTIONSare never checked, so those handlers must not change state. -
The
Origin/Hostfallback does not know the scheme, so for browsers that send noSec-Fetch-Sitea request fromhttp://example.comtohttps://example.comis allowed. The HSTS header thatuseSecurityHeaders()sends by default mitigates this. -
Behind a proxy that rewrites
Host, browsers withoutSec-Fetch-Sitehit the fallback and get403. PreserveHostat the proxy, or add your public origin totrustedOrigins. -
WebSocket upgrade requests are not checked. If a gateway authenticates with cookies, validate the handshake’s
Originyourself. -
Non-browser clients (mobile apps, CLIs, other servers) carry neither header and pass, which is correct: CSRF abuses credentials that a browser attaches automatically. Evergreen browsers have sent
Sec-Fetch-Sitesince 2023; browsers that send neither header look like non-browser clients and are allowed.
Token-based CSRF protection
When you must protect users of browsers that send neither header, or you want defence in depth, add a token scheme: the
server hands the client a token, stores a secret in a cookie or session, and rejects state-changing requests that do not
send the matching token, typically in a header. It needs client changes and a platform-specific package. The secret comes
from configuration, never from source code (generate one with openssl rand -hex 32).
Express: csrf-csrf
csrf-csrf implements the double-submit-cookie pattern. Its doubleCsrf() requires getSecret and getSessionIdentifier;
it needs cookie-parser or a session middleware registered first.
import cookieParser from 'cookie-parser';
import { doubleCsrf } from 'csrf-csrf';
import type { Request } from 'express';
export function useDoubleSubmitCsrf(app: NestExpressApplication) {
const config = app.get(ConfigService);
const secret = config.getOrThrow<string>('CSRF_SECRET');
const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
getSecret: () => secret,
// Bind the token to whatever identifies the visitor, here a session cookie.
getSessionIdentifier: (req: Request) => req.cookies?.['sid'] ?? '',
cookieName: 'csrf',
cookieOptions: { sameSite: 'lax', secure: false, httpOnly: true },
getCsrfTokenFromRequest: (req: Request) => req.headers['x-csrf-token'],
});
app.use(cookieParser());
app.use('/csrf-token', (req: Request, res: any) => {
res.json({ token: generateCsrfToken(req, res) });
});
app.use(doubleCsrfProtection);
}
secure: false is only for the local test; in production keep the default (secure: true) behind HTTPS. A client first
calls GET /csrf-token, which sets the csrf cookie and returns the token, then sends it as x-csrf-token. In the test, a
POST /books without the token returned 403 with {"statusCode":403,"message":"invalid csrf token"}, GET /csrf-token
returned 200 with a string token and a csrf cookie, and the POST with the header returned 201.
curl -c jar -b jar http://localhost:3000/csrf-token
curl -b jar -H 'x-csrf-token: <token>' -H 'content-type: application/json' \
-d '{"title":"Dune"}' http://localhost:3000/books
Source: NestJS documentation: CSRF protection and csrf-csrf.
Fastify: @fastify/csrf-protection
The plugin needs a storage plugin registered first (@fastify/cookie, @fastify/session or @fastify/secure-session) and
adds reply.generateCsrf() and a csrfProtection hook function. Its hook checks every method it is attached to, so wrap it
to let the safe methods through.
import fastifyCookie from '@fastify/cookie';
import fastifyCsrf from '@fastify/csrf-protection';
export async function useFastifyCsrf(app: NestFastifyApplication) {
const config = app.get(ConfigService);
await app.register(fastifyCookie, {
secret: config.getOrThrow<string>('COOKIE_SECRET'),
});
await app.register(fastifyCsrf, {
cookieOpts: { signed: true, httpOnly: true, sameSite: 'lax' },
getToken: (req) => req.headers['x-csrf-token'] as string | undefined,
});
const fastify = app.getHttpAdapter().getInstance();
fastify.get('/csrf-token', async (_req, reply) => ({
token: reply.generateCsrf(),
}));
fastify.addHook('onRequest', (req, reply, done) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
done();
return;
}
fastify.csrfProtection(req, reply, done);
});
}
A test shows the same sequence: POST without a token returns 403 (Missing csrf secret), GET /csrf-token sets a
_csrf cookie and returns a token, and the POST carrying the header returns 201. Attaching
csrfProtection to every request without the wrapper also rejects GET /csrf-token with 403, which is the pitfall the wrapper avoids.
Choosing, and combining
-
Browser-facing application with cookie authentication: enable
useSecurityHeaders()andenableCsrfProtection(), set cookiesSameSite=LaxorStrictandHttpOnly(see Cookies & Sessions), and add a token scheme only if old browsers matter. -
API consumed with
Authorization: Bearerheaders by a single-page application: CSRF is not the threat, since the browser does not attach that header by itself, but CSP, CORS and the other headers still apply. -
Server-to-server API: none of these matter; authenticate and rate-limit instead (see Rate Limiting & Encryption).
-
Where each fits in a wider review, see Security Best Practices.
Express and Fastify differences
| Topic | Express | Fastify |
|---|---|---|
Built-in |
middleware |
|
Helmet package |
|
|
Default CORS methods |
|
|
|
|
|
Token CSRF package |
|
|
Removing a header set by |
not covered by the documentation |
|
CSRF exclusions |
matched against the request URL |
matched before |
See Fastify & HTTP Platforms for the other adapter differences.
Where the book is outdated
Books written for NestJS 9 to 11 describe security as "install helmet, call app.use(helmet()), call
app.enableCors(), add `csurf`". Today:
-
csurfis deprecated and unmaintained, and the current Nest documentation shows other options. The built-in origin-based protection is the first choice, andcsrf-csrf(Express) or@fastify/csrf-protection(Fastify) are the token alternatives. -
app.use(helmet())is no longer the only way:useSecurityHeaders()needs no package, but does not support nonce functions in CSP directives. -
Snippets that call
app.enableCors()and assume the same methods on both platforms are wrong on Fastify. -
Examples that hardcode a CSRF or cookie secret in
main.tsshould read it fromConfigService.
What changed recently
-
12.1.0 (2026-09-23):
app.useSecurityHeaders()andapp.enableCsrfProtection()were added, together with adapter-agnostic cookies. Both exist in the 12.1.2 packages (their signatures are in@nestjs/common’s `NestApplicationinterface), and the examples above ran against 12.1.2. -
On NestJS 12.0 and earlier, use Helmet and a token-based CSRF package; the methods above do not exist there.
-
The CSP default is Helmet 8’s, and the CORS adapter defaults come from
corsand@fastify/cors; a major version of either package can change them, so setmethodsand the CSP directives you rely on explicitly.
Pitfalls
-
Calling the methods after
app.init()orapp.listen(), or twice, throws. -
Registering
app.use()middleware beforeuseSecurityHeaders()orenableCsrfProtection()lets that middleware answer without headers and without the CSRF check. -
Putting
'unsafe-inline'or a CDN host in a production CSP to make Swagger UI or GraphiQL work. -
Relying on HSTS from a plain-HTTP deployment: browsers ignore it there. Terminate TLS in front of the application and keep
strictTransportSecurityon. -
Treating CORS as access control: it only instructs browsers.
-
Checking CSRF on a
GETthat changes state: safe methods are never checked. -
Excluding a CSRF route with a predicate on
request.url. -
Hardcoding the CSRF or cookie secret.