Simple CSRF Protection Plugin
Use SimpleCsrfProtectionHandlerPlugin to reject requests that a browser reports as initiated by another site.
How It Works
The plugin decides on Sec-Fetch-Site, the Fetch Metadata header reporting who initiated a request:
Sec-Fetch-Site |
initiated by | |
|---|---|---|
same-origin |
your own pages | allowed |
same-site |
another origin on your site | rejected unless listed in origin or allowSameSite |
cross-site |
another site | rejected unless listed in origin |
none |
no page, such as a link opened from an email | rejected |
Requests without Fetch Metadata pass through, such as those from curl, mobile apps, and other non-browser clients.
Limitations
- Fetch Metadata is Baseline widely available, supported by every major browser since Safari 16.4 in March 2023. Older browsers and header-stripping proxies pass through unchecked.
- Browsers send Fetch Metadata only to trustworthy URLs: HTTPS and
localhost. Over plain HTTP the headers are absent while cookies are not, so every request passes, andlocalhostqualifying hides this in development. - Keep authentication cookies on
SameSite=Laxat minimum, and preferSameSite=Strictor a synchronizer token for high-value requests.
Setup
import { class SimpleCsrfProtectionHandlerPlugin<T extends Context>Adds basic Cross-Site Request Forgery (CSRF) protection by rejecting requests a browser
reports as initiated by another site, or by no page at all such as a link from an email.SimpleCsrfProtectionHandlerPlugin } from '@orpc/server/plugins'
const const handler: RPCHandler<{
headers?: IncomingHttpHeaders;
} & object>
handler = new new RPCHandler<{
headers?: IncomingHttpHeaders;
} & object>(router: Router<{
headers?: IncomingHttpHeaders;
} & object>, options?: NoInfer<RPCHandlerOptions<{
headers?: IncomingHttpHeaders;
} & object>>): RPCHandler<{
headers?: IncomingHttpHeaders;
} & object>
Serves an oRPC router over the RPC protocol using the Fetch API
(Request/Response), supported by modern runtimes like Deno, Bun,
Cloudflare Workers, and browsers.RPCHandler(const router: {
planet: {
list: ImplementedProcedure<{
headers?: IncomingHttpHeaders;
} & object, object, ZodObject<{
limit: ZodOptional<ZodNumber>;
cursor: ZodDefault<ZodNumber>;
}, $strip>, ZodArray<ZodObject<{
id: ZodNumber;
name: ZodString;
description: ZodOptional<ZodString>;
}, $strip>>, object>;
find: ImplementedProcedure<{
headers?: IncomingHttpHeaders;
} & object, object, ZodObject<{
id: ZodNumber;
}, $strip>, ZodObject<...>, object>;
create: ImplementedProcedure<...>;
};
}
router, {
FetchHandlerOptions<{ headers?: IncomingHttpHeaders; } & object>.plugins?: FetchHandlerPlugin<{
headers?: IncomingHttpHeaders;
} & object>[] | undefined
plugins: [
new new SimpleCsrfProtectionHandlerPlugin<{
headers?: IncomingHttpHeaders;
} & object>(options?: SimpleCsrfProtectionHandlerPluginOptions<{
headers?: IncomingHttpHeaders;
} & object>): SimpleCsrfProtectionHandlerPlugin<{
headers?: IncomingHttpHeaders;
} & object>
Adds basic Cross-Site Request Forgery (CSRF) protection by rejecting requests a browser
reports as initiated by another site, or by no page at all such as a link from an email.SimpleCsrfProtectionHandlerPlugin(),
],
})
Trusting Other Origins
Only your own origin is trusted by default. Use the same allowlist you pass to the CORS Plugin:
const origin = ['https://app.example.com']
const handler = new RPCHandler(router, {
plugins: [
new CORSHandlerPlugin({ origin, credentials: true }),
new SimpleCsrfProtectionHandlerPlugin({ origin }),
],
})
origin also accepts a single string, or a function. Every entry must be a specific origin, since there is no wildcard:
const plugin = new SimpleCsrfProtectionHandlerPlugin({
origin: (origin, options) => {
return origin.endsWith('.example.com') ? origin : undefined
},
})
Set allowSameSite: true to trust your whole site at once, and only when you control every subdomain, since SameSite cookies do not distinguish them:
const plugin = new SimpleCsrfProtectionHandlerPlugin({
allowSameSite: true,
})
Restricting Fetch Modes
A trusted site reaches your API by any means: fetch, forms, links, and <img> all pass. Restrict it to scripted requests if your app renders user-supplied URLs:
const plugin = new SimpleCsrfProtectionHandlerPlugin({
allowModes: ['cors', 'same-origin'],
})
allowModes matches Sec-Fetch-Mode: navigate for forms and links, no-cors for <img> and <script>. Leave it unset if you rely on HTML forms, including through the Method Override Plugin. Requests without the header are never rejected.
Learn More
For implementation details, see the source code.