I was building a payment form last week when a thought hit me. The form worked perfectly in testing, but I kept wondering: what if someone tricked a logged-in user into submitting it? This isn’t just theory. Real applications face this threat daily. It’s called Cross-Site Request Forgery, or CSRF. Today, I want to show you how to build a solid defense against it using Express.js and JWT. This guide is for developers who want to move beyond basic tutorials. We’ll build a system ready for real-world use. If you find this helpful, please share it with your network at the end.
Let’s start with the core problem. CSRF attacks are sneaky. They don’t steal your password. Instead, they misuse the trust your browser has with a website. Imagine you’re logged into your bank. A malicious site you visit could secretly send a request from your browser to transfer money. Your browser automatically sends your session cookies, making the request look legitimate to the bank’s server. The server sees a valid session and processes the transfer. The user often has no idea it happened.
How does this happen in practice? An attacker might embed a hidden form or image on their site. When your browser loads that page, it triggers a request to the vulnerable site. If you’re logged in, the attack succeeds. This is why protecting state-changing actions—like POST, PUT, DELETE requests—is critical. GET requests should be safe and not change data, but we still need to be careful.
So, how do we stop it? The main strategy is to ensure a request comes from a page our application actually generated. We need a secret value that the attacker cannot guess or steal. This value must be unique per user session and validated on the server for every sensitive request. There are a few proven patterns to do this. We’ll focus on two: the Double-Submit Cookie pattern and the Synchronizer Token pattern. Both are effective when implemented correctly.
Have you considered where an attacker might hide a malicious request? It could be in an image tag, a script, or a hidden form. The key is that the attacker cannot read cookies from your domain due to the same-origin policy. However, the browser will send them automatically with any request to your domain. This is the loophole we must close.
Let’s set up our project. We’ll use Express.js as our framework. First, create a new directory and initialize it. We’ll need several packages for security, tokens, and storage.
npm init -y
npm install express helmet cors cookie-parser jsonwebtoken redis ioredis uuid
npm install -D typescript @types/express @types/node ts-node nodemon
We use helmet for security headers and cors to control cross-origin requests. jsonwebtoken handles our authentication tokens. redis and ioredis are for storing session data if we use the Synchronizer Token pattern. uuid helps generate unique identifiers.
Now, let’s build the Double-Submit Cookie pattern. It’s simpler and stateless. The idea is to set a random token in a cookie when the user visits our site. Then, for any state-changing request, the frontend must send that same token in a custom HTTP header. The server compares the token from the cookie with the token from the header. If they match, the request is legitimate.
Why does this work? An attacker’s site cannot read the cookie value due to browser security rules. They also cannot set a custom header on a request triggered by a <form> or <img> tag. So, they cannot make a valid request. Let’s write the service to generate and verify these tokens.
// csrfService.js
const crypto = require('crypto');
class CsrfService {
static generateToken() {
// Create a cryptographically strong random string
return crypto.randomBytes(32).toString('hex');
}
static setTokenCookie(res, token) {
// Set the token in a cookie. httpOnly is false so JavaScript can read it.
res.cookie('csrf_token', token, {
httpOnly: false,
secure: process.env.NODE_ENV === 'production', // Use HTTPS in production
sameSite: 'strict', // Important for CSRF protection
maxAge: 60 * 60 * 1000, // 1 hour
path: '/',
});
}
static validateRequest(req) {
const cookieToken = req.cookies.csrf_token;
const headerToken = req.headers['x-csrf-token'];
// Both must exist and match exactly
if (!cookieToken || !headerToken) {
return false;
}
// Use a constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(cookieToken),
Buffer.from(headerToken)
);
}
}
module.exports = CsrfService;
The sameSite: 'strict' cookie attribute is a powerful ally. It tells the browser not to send this cookie with cross-site requests. This provides another layer of defense. However, we shouldn’t rely on it alone, as browser support varies.
Next, we need middleware to apply this protection. We should skip safe methods like GET, HEAD, and OPTIONS. We might also want to exclude certain public API endpoints. The middleware will check the token on relevant requests.
// csrfMiddleware.js
const CsrfService = require('./csrfService');
function csrfProtect(options = {}) {
return (req, res, next) => {
// Define safe HTTP methods
const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
if (safeMethods.includes(req.method)) {
return next();
}
// Allow paths to be excluded (e.g., webhook endpoints)
if (options.excludePaths) {
const isExcluded = options.excludePaths.some(path => req.path.startsWith(path));
if (isExcluded) {
return next();
}
}
// Perform the validation
if (!CsrfService.validateRequest(req)) {
return res.status(403).json({
error: 'Invalid CSRF token',
code: 'FORBIDDEN'
});
}
// Token is valid, proceed
next();
};
}
module.exports = csrfProtect;
Now, how does the frontend get the token? When the user loads our single-page application, we need to make an initial request to get a CSRF token. We can do this with a simple endpoint.
// server.js - Example endpoint
app.get('/api/csrf-token', (req, res) => {
const token = CsrfService.generateToken();
CsrfService.setTokenCookie(res, token);
// Also send it in the JSON response for convenience
res.json({ csrfToken: token });
});
On the frontend, after fetching this token, you must store it and attach it to all subsequent POST, PUT, PATCH, and DELETE requests. Here’s a simple example using the Fetch API.
// frontend.js
let csrfToken;
async function fetchCsrfToken() {
const response = await fetch('/api/csrf-token', { credentials: 'include' });
const data = await response.json();
csrfToken = data.csrfToken;
// The cookie is also set automatically by the browser
}
// Use this function for all state-changing requests
async function makeSecureRequest(url, method, body) {
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // Attach the token from memory
},
body: JSON.stringify(body),
credentials: 'include' // Important to send cookies
});
return response;
}
// Example: Updating a user profile
fetchCsrfToken().then(() => {
makeSecureRequest('/api/profile', 'PUT', { name: 'New Name' });
});
But what about JWT? Often, we use JSON Web Tokens for authentication, stored in client-side memory, not cookies. Does this make us safe from CSRF? Not entirely. If you store your JWT in localStorage or sessionStorage, CSRF attacks are not a direct threat for stealing it. However, many apps still use cookies for JWTs, especially to avoid XSS risks. If your JWT is in a cookie, you need CSRF protection. Our Double-Submit pattern works perfectly here. The CSRF token is a separate cookie, and the JWT can be an httpOnly cookie for better security against XSS.
Let’s look at the Synchronizer Token pattern. It’s more stateful. Instead of relying on a cookie, the server stores a secret token associated with the user’s session. The frontend gets this token and sends it back in a header or form field. The server then checks its stored session to validate it. This pattern is very strong but requires server-side storage, like Redis or a database.
Which pattern is better? The Double-Submit pattern is simpler and works well for stateless, RESTful APIs. The Synchronizer pattern is more robust for traditional server-rendered applications. For a modern Express.js API using JWT, I often recommend the Double-Submit pattern. It’s easier to scale.
We must also think about testing. How do you know your protection works? Write automated tests. Use a tool like Supertest to simulate attacks.
// test/csrf.test.js
const request = require('supertest');
const app = require('../server'); // Your Express app
describe('CSRF Protection', () => {
it('should reject a POST request without a CSRF token', async () => {
const response = await request(app)
.post('/api/data')
.set('Cookie', ['session=valid_session_id']) // Has session but no CSRF token
.send({ action: 'update' });
expect(response.statusCode).toBe(403);
});
it('should accept a POST request with a valid CSRF token', async () => {
// First, get a valid CSRF token via a GET request
const getResponse = await request(app).get('/api/csrf-token');
const csrfCookie = getResponse.headers['set-cookie'];
const { csrfToken } = getResponse.body;
// Use that token in a POST request
const postResponse = await request(app)
.post('/api/data')
.set('Cookie', csrfCookie)
.set('X-CSRF-Token', csrfToken)
.send({ action: 'update' });
expect(postResponse.statusCode).toBe(200);
});
});
What happens when your frontend and backend are on different domains? You need to configure CORS properly. The frontend must include credentials, and the backend must allow the specific origin. The CSRF token cookie must also be set with the correct domain and sameSite attribute. Sometimes, you might need sameSite: 'none' and secure: true for cross-domain setups, but this requires careful consideration.
Remember, security is a layered approach. CSRF protection is one layer. Also use helmet to set security headers, validate all user input, and keep your dependencies updated. Never expose sensitive error details to the client.
Building this made me realize how subtle web security can be. A single missed check can open a door. Implementing CSRF protection correctly gives your users confidence and protects their data. It turns a potential weakness into a documented, tested strength.
I hope this walkthrough gives you a clear path to securing your Express.js applications. This isn’t just about following steps; it’s about understanding the ‘why’ behind each line of code. What part of your current application would you check for CSRF vulnerabilities first? Try applying these concepts to a personal project. If this guide helped you see web security in a new light, please share your thoughts in the comments below. Sharing this article could help another developer lock down their app. Let’s build a more secure web, one project at a time.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva