Express Authentication Cookies and JWTs
Learn how to use both JWTs and Cookies in Express to authenticate a request and how to protect JWT cookies from cross-site request forgery (CSRF) attacks. To demonstrate, we will be using the jsonwebtoken and cookie-parser npm libraries.
Table of Contents 📖
- Storing JWTs in a Cookie
- Storing JWT in a Cookie and CSRF
- Express App Setup
- Creating a JWT and Setting a Cookie
- Creating JWT and Cookie Authorization Middleware
Storing JWTs in a Cookie
JWTs are commonly used to implement authorization. Specifically, the user logs in with their credentials and the server returns a JWT. This JWT is then stored on the client. As the JWT is stored on the client, it is important to store it securely. There are a few ways to store a JWT on the client each with their own pros and cons. One way is to store the JWT in an HTTP cookie. Storing a JWT inside a cookie is great because cookies have some flags to protect the JWT. For example:
- Secure - cookie can only be transmitted over HTTPS
- SameSite - helps mitigate cross-site request forgery attacks
- HttpOnly - the cookie cannot be accessed through client side scripts
Storing JWT in a Cookie and CSRF
However, storing a JWT inside a cookie is not foolproof as they are succeptible to cross-site request forgery attacks (CSRF). A CSRF attack is when an attacker tricks a user into making an unwanted request. Cookies are vulnerable to CSRF because browsers automatically include cookies in the request. Therefore, if the attacker tricked the user to sending a request (such as through clicking a link) the cookie containing the JWT would be sent too. However, we can mitigate CSRF attacks by giving the cookie a short lifetime and setting its SameSite attribute to Strict or Lax. This ensures that the authentication cookie isn't sent in cross site requests.
Express App Setup
To demonstrate, we will be using the libraries jsonwebtoken and cookie-parser.
- 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 then 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,
sameSite: 'strict',
// 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
Creating a JWT and Setting a Cookie
First, lets focus on how the JWT is created and how the cookie is set on the client. This is done in the /login route.
// 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,
sameSite: 'strict',
// 1 h
maxAge: 60 * 60 * 1000
});
return res.status(200).send('<h1>Cookie has been set!</h1>');
});
Here are the steps involved:
-
Get the username from the request body
-
Create a JWT with the jwt.sign method of jsonwebtoken. This method returns a JWT as a string. The first argument is the payload to house in the JWT. The second argument is the secret used to sign the token. It is important that this value is stored safely as if an attacker gets their hands on it they can forge tokens. It should be stored as an environment variable and hidden from the public.
-
Add the JWT to a cookie. Specifically, this will create a Set-Cookie header on the response. The first argument is the key to identify the cookie, the second is the cookie value, and the third is a configuration object for setting flags on the cookie.
It is the HTML form in our public directory that sends the POST request to this 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.
Creating JWT and Cookie Authorization Middleware
Now lets focus on our global authorization middleware.
// 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>');
}
});
To protect routes with this middleware, it simply needs to be placed before the protected route in the middleware stack. This is why it is after the /login route but before the /profile route. Here is what it does:
-
Get the JWT from the cookie. Specifically, this will be looking for a Cookie header with the key MY_JWT. If no token is provided, return an unauthorized message and status.
-
If there is a cookie present, verify it with the jsonwebtoken library. This method returns the JWT payload if the token is valid. In other words, if the token hasn't been forged, tampered with, or expired. If the JWT is valid then attach the user information to the response and call the following middleware.
-
If the JWT is invalid, clear the cookie and send back an unauthorized message and status code.
Here is if we don't have a cookie set in the request to a protected route.
curl localhost:4001/profile
<h1>Unauthorized</h1>
Now lets send a request with our cookie set.
curl localhost:4001/profile -H "Cookie: MY_JWT=eyJhbGciOiJIUzI1NiJ9.d2l0dGNvZGU.qWH_1sFxdwrlhBC6tI8GmKbKPG3xxEHND96DegSHomg"
<h1>Hello wittcode!</h1>