JWT and Cookie CSRF Example
Learn how to protect a web application that uses JWT cookies from cross-site request forgery (CSRF) attacks using Node. We will also learn why JWT cookies are particularly vulnerable to CSRF and what a CSRF attack is with a live example.
Table of Contents 📖
- What is a Cross-Site Request Forgery Attack?
- Cookies and CSRF
- Storing JWTs in a Cookie
- CSRF Vulnerable App Setup
- CSRF Demonstration
- Defending CSRF
What is a Cross-Site Request Forgery Attack?
A cross-site request forgery (CSRF) attack is where an attacker tricks a user into making an unwanted request. For example, say a user gets an email from an attacker containing a link. If the user clicks this link, it could perform an unwanted action on the user's behalf.
Cookies and CSRF
Cookies are often leveraged in successful CSRF attacks because browsers automatically include cookies in requests. Therefore, if a user clicks a malicious link that an attacker sent them, the request will contain all the user's cookies for that domain.
Storing JWTs in a Cookie
JWTs are commonly used to implement authorization and are often stored inside an HTTP cookie. Therefore, CSRF attacks can use these cookies to perform unwanted actions on sites that require authorization. In other words, if the link the attacker provides is to a website that requires authentication, then the attacker could use the JWT cookie to bypass this.
CSRF Vulnerable App Setup
To demonstrate CSRF, we will use a Node application with the libraries express, jsonwebtoken, and cookie-parser.
- express - minimalist web framework
- jsonwebtoken - implements JWTs in a Node app
- cookie-parser - express middleware that parses the Cookie header and populates req.cookies with the values
Our code will consist of a login page, a protected route, and a global JWT authentication middleware.
import express, {Request, Response} from 'express';
import cookieParser from 'cookie-parser';
import jwt from 'jsonwebtoken';
import {fileURLToPath} from 'url';
import path, {dirname} from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PORT = 4001;
const SECRET = 'a09353a06dd0b7ea5832c9d57a1ff524b91b7f96048c75e58a5a8cda3aaf1f68';
const MY_JWT = 'MY_JWT';
const app = express();
app.use(express.static(path.resolve(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({extended: true}));
app.use(cookieParser());
// Login HTML page
app.get('/', (req, res) => {
return res.status(200).sendFile(path.resolve(__dirname, 'public', 'index.html'));
});
// Handle login information
app.post('/login', (req, res) => {
// Generate JWT
const {username} = req.body;
const token = jwt.sign({username}, SECRET, {expiresIn: '1h'});
// Add the jwt to a Cookie
res.cookie(MY_JWT, token, {
httpOnly: true,
secure: true,
// 1 h
maxAge: 60 * 60 * 1000
});
return res.status(200).send('<h1>Cookie has been set!</h1>');
});
// Protected routes
// Verify token middleware
app.use((req: Request, res: Response, next) => {
try {
// Get JWT
const token = req.cookies[MY_JWT];
if (!token) {
return res.status(403).send('<h1>Unauthorized</h1>');
}
// Get user from JWT
const user = jwt.verify(token, SECRET);
res.locals.user = user;
return next();
} catch (err) {
res.clearCookie(MY_JWT);
return res.status(403).send('<h1>Unauthorized</h1>');
}
});
// Get user profile
app.get('/profile', (req, res) => {
const {username} = res.locals.user;
return res.send(`<h1>Hello ${username}!</h1>`);
});
// Listen on port
app.listen(PORT, () => {
console.log(`Server listening on port: ${PORT}`);
});
Here we have 4 routes.
- / - returns HTML login page
- /login - accepts a JSON payload from the HTML login form
- global authentication middleware to check validity of a JWT
- /profile - a protected route that requires a JWT to access
Filling out the login form sends a POST request that sets a cookie on the client. After the cookie has been set, and as long as it has not expired, the user can access the protected route.
In the GIF above, note how the Set-Cookie header is returned in the response headers. It also contains the flags that we set on it and the name of MY_JWT. The value is the JWT itself.
CSRF Demonstration
Now, that a cookie is set on the client for localhost:4001, lets see what happens if a user clicks a link in an email pointing to a protected route.
We can see that the cookies are sent with the request and because it contains a JWT, the request was authenticated successfully. Now this link here isn't malicious but imagine if the url contained query parameters to set user information. Or if the request was a POST request containing a malicious payload. The possibilities are endless.
Defending CSRF
The great thing about storing a JWT inside a cookie is that cookies provide many flags that can restrict what the cookie, and hence JWT, is capable of doing. To prevent CSRF, we can set the SameSite attribute.
// Handle login information
app.post('/login', (req, res) => {
// Generate JWT
const {username} = req.body;
const token = jwt.sign({username}, SECRET, {expiresIn: '1h'});
// Add the jwt to a Cookie
res.cookie(MY_JWT, token, {
httpOnly: true,
secure: true,
// IMPORTANT LINE TO MITIGATE CSRF!
sameSite: 'strict',
// 1 h
maxAge: 60 * 60 * 1000
});
return res.status(200).send('<h1>Cookie has been set!</h1>');
});
The SameSite attribute prevents browsers from sending a cookie in cross site requests. Hence, providing protection against CSRF attacks. There are 3 possible values for the SameSite attribute.
- none - no protection, cookie attached in all cross site requests
- lax - cookie sent in cross site requests if it is a GET request and the request came from a top-level navigation of the user themselves
- strict - cookie will not be sent by browser in any cross site request
Now if we remove the cookie and login again, the email link click will not work as the cookie was not sent with the request.
Note that not all browsers support this SameSite feature but as time progresses more will. As a result, further protecting an application with a CSRF token would add even more security, but that's unrelated to the cookie itself and will be for another time.