CSRF Anti-Attack Guide
Introduction
CSRF (Cross-Site Request Forgery) is a type of attack targeting user sessions. Attackers trick the victim's browser into sending requests (accompanied by identification cookies) to websites where they are logged in without permission.
To prevent CSRF, we have 3 main strategies:
- Method 1: Check Origin / Referer headers Check if the Origin or Referer header matches the server domain. If they differ, block them immediately at the NextJS Proxy layer before forwarding to the server.
- Method 2: Configure SameSite for Cookies When setting cookies for the client, you must set the SameSite=Strict or SameSite=Lax attribute. In this case, if the request originates from a different website, the browser will refuse to attach the Cookie, NextJS receives an empty request and will return a 401 Unauthorized error.
- Method 3: Use CSRF Token Require the client to send a Token (generated randomly for each user or using JWT) in the Header. Since a fake site cannot have this token, the outgoing request will miss the token and we only need to check this condition to block requests sent to the core service.
The attack process is as follows: A hacker creates a website fake.com, when a user accesses it, it will call your api at yourdomain.com. Because the browser sees this as a request to your domain, it will automatically attach the cookie to the request, thus the Hacker can perform actions on behalf of the user.
If you configure cookie SameSite=Strict or SameSite=Lax, the browser will no longer automatically attach cookies for such api call operations and you have avoided most CSRF-related issues.
Blocking by cookie usually applies to users after login. For operations with public permissions for everyone, cookies will not be checked, thus hackers can still call through your service.
- If you want to thoroughly block and only allow users using your web to use the services you provide, check the Origin / Referer headers.
- When the browser performs an api call, it will attach Origin / Referer fields to the header, you just need to check if this value matches your server to block it immediately.
Using a CSRF Token is a more advanced way to add a layer of security to your system, because Origin / Referer header values can be easily modified using Postman or on old browser versions with poor security. If you encrypt specifically to create a unique token for each user, the hacker will not be able to use your service without the appropriate token.
Here, if you wonder how a NextJS server not configured to allow CORS can receive api calls from other websites to our server and why we need the security operations above:
- Please note an important point that CORS is only a browser mechanism. When you call an api cross-site, the browser actually still sends the request and only when there is a response (if the browser does not support cross-site for that api) does the browser block further processing.
- You can check by calling an api cross-site and you will still see logs on the server processing it, which is why we need additional check operations to stop this processing.
- Note that in most systems, if you deploy a CSRF Token using JWT where the token information is stored in LocalStorage or SessionStorage instead of a cookie, you have almost avoided CSRF risks. Each web app on the browser has separate memory. If a hacker calls an api from fake.com, they cannot read data from LocalStorage in your yourdomain.com.
- However, doing so faces another risk which is XSS (Cross-Site Scripting), which I will explain in detail in subsequent articles.
Detail
In this article, I will still guide using NextJS as a reverse proxy and the server as NestJS (you can replace it with any server you are familiar with).
NestJS project
Create file src/controller/auth.controller.ts, here I only focus on configuring cookies to avoid CSRF, so I will not talk specifically about the JWT part, you can see more in my previous articles.
import {
Body,
Controller,
Post,
Res,
} from '@nestjs/common'
import express from 'express'
import {LoginDto} from 'src/dto/auth.dto'
import {AuthService} from 'src/service/auth.service'
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
async login(
@Body() body: LoginDto,
@Res({passthrough: true}) response: express.Response
) {
const token = await this.authService.login(body)
response.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
})
return token
}
}
- Check NODE_ENV to determine the environment because when secure is true, it works on HTTPS, while in your development environment, it is HTTP.
- The sameSite property in CookieOptions determines whether the browser allows automatically attaching this Cookie to Requests originating from another website (Cross-Origin). This is the key factor in fighting CSRF attacks.
- Below are the detailed differences between the values:
- strict (Maximum Security)
- How it works: The browser only sends the Cookie if the request originates from the same website that owns the Cookie (Same-Site).
- Behavior: If a user is on a website and clicks a link leading to your site, the browser will not send the Cookie. The user will be in an "unlogged" state at that first page load (only when they manually type the URL or reload the page will the Cookie work).
- Use case: Banking websites, e-wallets, or payment gateways that need absolute security for data change actions.
- lax (Default for modern browsers)
- How it works: This is a balance between security and user experience. The browser blocks Cookies for background requests (like API calls), but allows sending Cookies for safe top-level navigations.
- When a user clicks a link from one website to yours, which is a public GET navigation operation, the cookie is attached and the user remains logged in. But if that fake.com site calls an api to your site (using fetch/axios), the cookie will not be attached and the CSRF attack fails.
- Use case: Suitable for most common websites (E-commerce, Social Networks, Dashboards) to maintain a smooth experience when users click links from Google/Facebook to their site.
- none (No restriction)
- How it works: The browser will send the Cookie in all cases, regardless of whether the request originates from inside or outside your website.
- Mandatory condition: If you set sameSite: "none", you must also set the secure: true attribute (Cookie only transmitted via HTTPS). Without secure: true, the browser will refuse to store this Cookie.
- Use case: When building embedded systems (Embed Component), chat widgets, or Third-party Cookie systems that need to share data across completely different domains.
- boolean (true or false)
- true: Equivalent to configuring strict. The browser will apply the strictest security policy.
- false: Completely turns off the SameSite feature. However, in practice on modern browser versions (Chrome, Edge, Safari), if you set false or leave it blank (undefined), the browser will automatically force it to the default value of lax to protect users.
NextJS project
Update file next.config.ts
import type {NextConfig} from 'next'
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: process.env.SERVER_HOST + '/:path*',
},
]
},
}
export default nextConfig
Create file proxy.ts at the same level as the app folder
import {NextResponse, type NextRequest} from 'next/server'
export function proxy(request: NextRequest) {
const {pathname} = request.nextUrl
const origin = request.headers.get('origin')
const referer = request.headers.get('referer')
const allowedDomain = process.env.HOST!
const isAllowed =
(origin && origin === allowedDomain) ||
(referer && referer.startsWith(allowedDomain))
if (!isAllowed) {
return NextResponse.json(
{message: 'Forbidden: Invalid Origin'},
{status: 403}
)
}
const tokenCookie = request.cookies.get('token')
let token = tokenCookie?.value
if (!token && !pathname.includes('/auth/login')) {
return NextResponse.json(
{message: 'Unauthorized: Missing token'},
{status: 401}
)
}
let tokenJson
if (token?.startsWith('j:')) {
token = token.slice(2)
tokenJson = JSON.parse(token!)
}
const headers = new Headers(request.headers)
const accessToken = tokenJson?.accessToken
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
console.log('headers', Object.fromEntries(headers.entries()))
return NextResponse.next({request: {headers}})
}
export const config = {
matcher: ['/api/:path*'],
}
Most security logic will reside here:
- Check header values of origin and referer, if it is not a request from NextJS then throw error 403.
- If attempting to use private services without a cookie then throw error 401.
- Then you just need to attach the Authorization field with the token value taken from the cookie into the header (this is exactly the CSRF Token).
Create file app/hook.ts used only to create hooks for api calls using Tanstack React Query
import {useMutation} from '@tanstack/react-query'
interface ApiResponse {
message: string
}
interface CreatePayload {
name: string
}
export interface LoginPayload {
username?: string
password?: string
}
const requestPostApi = async (
url: string,
payload: any
): Promise<ApiResponse> => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(`An error occurred`)
}
return response.json()
}
export function useLogin() {
return useMutation({
mutationFn: (payload: LoginPayload) =>
requestPostApi('/api/auth/login', payload),
})
}
export function useCreate() {
return useMutation({
mutationFn: (payload: CreatePayload) =>
requestPostApi('/api/test/create', payload),
})
}
Create file app/login/page.tsx with simple functionality to navigate to the Welcome page after successful login
'use client'
import {LockOutlined, UserOutlined} from '@ant-design/icons'
import {Alert, Button, Form, Input} from 'antd'
import {useRouter} from 'next/navigation'
import {useLogin, type LoginPayload} from '../hook'
export default function LoginPage() {
const router = useRouter()
const loginMutation = useLogin()
const isAuthPending = loginMutation.isPending
const authError = loginMutation.error
const onFinishLogin = (values: LoginPayload) => {
loginMutation.mutate(values, {
onSuccess: () => {
router.push('/feature/security/csrf/welcome')
},
})
}
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-6 bg-gray-50 rounded-xl border border-gray-200 max-w-md mx-auto my-10 shadow-sm">
<h2 className="text-xl font-semibold text-gray-800 mb-6">
Account Login
</h2>
<Form
name="login_form"
className="w-full"
initialValues={{username: '', password: ''}}
onFinish={onFinishLogin}
layout="vertical"
>
<Form.Item
name="username"
rules={[{required: true, message: 'Please input your Username!'}]}
>
<Input
prefix={<UserOutlined className="text-gray-400" />}
placeholder="Username"
size="large"
disabled={isAuthPending}
/>
</Form.Item>
<Form.Item
name="password"
rules={[{required: true, message: 'Please input your Password!'}]}
>
<Input.Password
prefix={<LockOutlined className="text-gray-400" />}
placeholder="Password"
size="large"
disabled={isAuthPending}
/>
</Form.Item>
{authError && (
<Alert
title={
authError instanceof Error ? authError.message : 'Login failed'
}
type="error"
showIcon
className="mb-4 rounded-lg"
/>
)}
<div className="flex gap-4 mt-2">
<Button
type="primary"
htmlType="submit"
size="large"
loading={isAuthPending}
className="flex-1 bg-blue-600 hover:bg-blue-500 font-medium rounded-lg"
>
Login
</Button>
</div>
</Form>
</div>
)
}
Create file app/welcome/page.tsx to test attached JWT from cookie into header automatically when calling api
'use client'
import {MessageOutlined, PlusOutlined} from '@ant-design/icons'
import {Button, Card} from 'antd'
import {useCreate} from '../hook'
export default function WelcomePage() {
const createMutation = useCreate()
const createData = createMutation.data
const handleCreateData = () => {
const payload = {name: 'Test Item'}
createMutation.mutate(payload)
}
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-6 bg-gray-50 rounded-xl border border-gray-200 max-w-md mx-auto my-10 shadow-sm">
<h2 className="text-2xl font-bold text-gray-800 mb-2 text-center">
Welcome Back!
</h2>
<p className="text-sm text-gray-500 text-center mb-6">
You have successfully logged in.
</p>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={handleCreateData}
className="bg-green-600 hover:bg-green-500 font-medium rounded-lg px-8 py-5 flex items-center justify-center w-full shadow-md"
>
Test Create API
</Button>
<div className="w-full mt-6">
{createData && (
<Card
title={
<div className="flex items-center gap-2 text-gray-700">
<MessageOutlined />
<span>Response Data</span>
</div>
}
className="w-full border border-gray-200 shadow-sm rounded-lg"
styles={{header: {backgroundColor: '#f9fafb'}}}
>
<p className="text-gray-700 font-mono bg-gray-50 p-3 rounded border border-gray-100 break-words text-sm">
{createData.message}
</p>
</Card>
)}
</div>
</div>
)
}
The results are as follows:
View request detail to see the browser has automatically attached Origin and Referer to Request Headers.
You can test by calling the API from a different origin and you will see a message blocked by CORS policy, however, checking the logs will still show that the NextJS server processed that request.
origin and referer fields are attached to the header by the browser. However, cookies are missing because they have been configured with SameSite=strict or SameSite=lax. We will rely on this information to determine the request's origin and block it immediately.{ pathname: '/api/test/create', headers: { accept: '*/*', accept-encoding: 'gzip, deflate, br, zstd', accept-language: 'vi,en-US;q=0.9,en;q=0.8,zh;q=0.7,zh-HK;q=0.6,zh-CN;q=0.5,zh-TW;q=0.4,ja;q=0.3', access-control-request-headers: 'cache-control,content-type,pragma', access-control-request-method: 'POST', cache-control: 'no-cache', connection: 'keep-alive', host: 'localhost:3000', origin: 'http://localhost:4000', pragma: 'no-cache', referer: 'http://localhost:4000/', sec-fetch-dest: 'empty', sec-fetch-mode: 'cors', sec-fetch-site: 'same-site', x-forwarded-for: '::1', x-forwarded-host: 'localhost:3000', x-forwarded-port: '3000', x-forwarded-proto: 'http' }}You can log the NextJS server to see the header attached with Authorization before sending to the core service.
headers {accept: '*/*',accept-encoding: 'gzip, deflate, br, zstd',accept-language: 'vi,en-US;q=0.9,en;q=0.8,zh;q=0.7,zh-HK;q=0.6,zh-CN;q=0.5,zh-TW;q=0.4,ja;q=0.3',authorization: 'Bearer eyJhbGciOiJIU1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0Ij7wrcNzc5NjgyMjg1LCJleHAiOjE3Nzk2ODI1ODV9.7HRGieHfidpkaTVK6zAkh2PNTRU-gvaJ3cghWWXAsnU',cache-control: 'no-cache',connection: 'keep-alive',content-length: '20',content-type: 'application/json',cookie: 'token value',host: 'localhost:3000',origin: 'http://localhost:3000',pragma: 'no-cache',referer: 'http://localhost:3000/feature/security/csrf/welcome',x-forwarded-for: '::1',x-forwarded-host: 'localhost:3000',x-forwarded-port: '3000',x-forwarded-proto: 'http'}
Happy coding!
Comments
Post a Comment