Skip to main content

Express Cookie Security

Introduction

Cookies are small pieces of data stored on a client's browser that help maintain state and user information across HTTP requests. In Express.js applications, cookies play a crucial role in maintaining sessions, remembering user preferences, and facilitating authentication. However, improperly secured cookies can expose your application to various security threats such as cross-site scripting (XSS), session hijacking, and cross-site request forgery (CSRF).

In this guide, we'll explore how to implement secure cookie practices in Express.js applications, covering essential security attributes, mitigation techniques for common vulnerabilities, and best practices for authentication flows.

Before diving into security considerations, let's set up the foundation for working with cookies in an Express application.

Installing Dependencies

First, we need to install the cookie-parser middleware which simplifies cookie handling:

bash
npm install cookie-parser

Basic Setup

Here's how to integrate the cookie parser into your Express application:

javascript
const express = require('express');
const cookieParser = require('cookie-parser');

const app = express();

// Add cookie parser middleware
app.use(cookieParser());

// Now you can set and access cookies
app.get('/', (req, res) => {
// Set a basic cookie
res.cookie('user', 'john', { maxAge: 900000 });
res.send('Cookie has been set');
});

// Access cookies
app.get('/read-cookie', (req, res) => {
const cookieValue = req.cookies.user;
res.send(`Cookie value: ${cookieValue}`);
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

The Secure Flag

The Secure flag ensures that cookies are only sent over HTTPS connections, preventing them from being transmitted over unencrypted HTTP connections.

javascript
// Setting a secure cookie
app.get('/set-secure-cookie', (req, res) => {
res.cookie('secureData', 'sensitive-information', {
secure: true, // Only sent over HTTPS
maxAge: 3600000 // 1 hour
});
res.send('Secure cookie set');
});

Important: In development environments where you might be using http://localhost, secure cookies won't be set. For testing, you can conditionally apply this attribute:

javascript
const isProduction = process.env.NODE_ENV === 'production';

app.get('/set-cookie', (req, res) => {
res.cookie('data', 'value', {
secure: isProduction, // Only use in production
maxAge: 3600000
});
res.send('Cookie set');
});

The HttpOnly Flag

The HttpOnly flag prevents client-side scripts from accessing cookies, providing protection against cross-site scripting (XSS) attacks:

javascript
app.get('/set-httponly-cookie', (req, res) => {
res.cookie('sessionToken', 'abc123', {
httpOnly: true, // Not accessible via JavaScript
maxAge: 3600000
});
res.send('HttpOnly cookie set');
});

The SameSite Attribute

The SameSite attribute helps protect against cross-site request forgery (CSRF) attacks by controlling when cookies are sent with cross-site requests:

javascript
app.get('/set-samesite-cookie', (req, res) => {
res.cookie('csrf', 'token123', {
sameSite: 'strict', // Only sent in same-site context
maxAge: 3600000
});
res.send('SameSite cookie set');
});

SameSite can have three values:

  • 'strict': Cookies only sent in same-site context
  • 'lax': Cookies sent during navigation to origin site
  • 'none': Cookies sent in all contexts (requires Secure flag)

Domain and Path Restrictions

Limiting the scope of cookies enhances security by restricting where cookies are sent:

javascript
app.get('/set-scoped-cookie', (req, res) => {
res.cookie('preferences', 'theme=dark', {
domain: '.example.com', // Valid for example.com and subdomains
path: '/dashboard', // Only sent for paths starting with /dashboard
maxAge: 3600000
});
res.send('Scoped cookie set');
});

Here's an example of setting a cookie with all security attributes properly configured:

javascript
app.get('/set-full-secure-cookie', (req, res) => {
res.cookie('sessionId', 'user_session_123', {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600000, // 1 hour in milliseconds
path: '/',
// domain: '.example.com', // Uncomment in production
});
res.send('Secure cookie set with all protections');
});

Practical Example: Implementing Secure Authentication Cookies

Let's build a simple authentication system that uses secure cookies for session management:

javascript
const express = require('express');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');

const app = express();

app.use(express.json());
app.use(cookieParser(process.env.COOKIE_SECRET || 'your-secret-key'));

// In a real app, you would store users in a database
const users = [
{ id: 1, username: 'user1', password: 'password1' }
];

// Helper function to generate secure session IDs
function generateSessionId() {
return crypto.randomBytes(32).toString('hex');
}

// In-memory session store (use Redis or another store in production)
const sessions = {};

// Login route
app.post('/login', (req, res) => {
const { username, password } = req.body;

// Find user (DEMO ONLY - use proper password hashing in production!)
const user = users.find(u => u.username === username && u.password === password);

if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}

// Create a new session
const sessionId = generateSessionId();
const nowInSeconds = Math.floor(Date.now() / 1000);
const expiresAt = nowInSeconds + (60 * 60); // 1 hour from now

sessions[sessionId] = {
userId: user.id,
expiresAt
};

// Set the secure session cookie
res.cookie('session', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 3600000, // 1 hour in milliseconds
path: '/',
signed: true // Uses the secret passed to cookieParser
});

res.json({ message: 'Login successful' });
});

// Protected route example
app.get('/profile', (req, res) => {
const sessionId = req.signedCookies.session;

if (!sessionId || !sessions[sessionId]) {
return res.status(401).json({ error: 'Unauthorized' });
}

const session = sessions[sessionId];

// Check if session has expired
if (session.expiresAt < Math.floor(Date.now() / 1000)) {
delete sessions[sessionId];
return res.status(401).json({ error: 'Session expired' });
}

// Find the user
const user = users.find(u => u.id === session.userId);

res.json({
message: 'Profile accessed successfully',
username: user.username
});
});

// Logout route
app.post('/logout', (req, res) => {
const sessionId = req.signedCookies.session;

if (sessionId && sessions[sessionId]) {
delete sessions[sessionId];
}

res.clearCookie('session', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/'
});

res.json({ message: 'Logged out successfully' });
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

Cross-Site Scripting (XSS) Protection

Beyond using HttpOnly cookies, consider implementing Content Security Policy headers:

javascript
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
// Add other directives as needed
}
}));

Cross-Site Request Forgery (CSRF)

While SameSite cookies help mitigate CSRF, for critical operations it's still recommended to use CSRF tokens:

javascript
const csrf = require('csurf');

// Setup CSRF protection
const csrfProtection = csrf({ cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
}});

// Example of protected form submission
app.get('/change-password', csrfProtection, (req, res) => {
// Pass the token to the view
res.render('change-password', { csrfToken: req.csrfToken() });
});

app.post('/change-password', csrfProtection, (req, res) => {
// The CSRF token is automatically checked by the middleware
// If valid, process the password change
res.send('Password changed successfully');
});

Here's how you would include the token in a form:

html
<form action="/change-password" method="post">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="password" name="newPassword" placeholder="New password">
<button type="submit">Change Password</button>
</form>

Modern browsers support cookie prefixes that enforce security attributes:

javascript
app.get('/set-prefixed-cookie', (req, res) => {
// __Secure- prefix requires the Secure flag
res.cookie('__Secure-ID', 'user123', {
secure: true,
httpOnly: true,
sameSite: 'strict'
});

// __Host- prefix requires Secure flag, no Domain, and Path=/
res.cookie('__Host-SessionID', 'abcd1234', {
secure: true,
httpOnly: true,
sameSite: 'strict',
path: '/'
// No domain attribute allowed with __Host- prefix
});

res.send('Prefixed cookies set');
});

Signed Cookies for Integrity

Express's cookie-parser supports signed cookies to ensure data hasn't been tampered with:

javascript
// Initialize with a secret
app.use(cookieParser('my_super_secret_key_12345'));

// Set a signed cookie
app.get('/set-signed', (req, res) => {
res.cookie('user', 'john', { signed: true });
res.send('Signed cookie set');
});

// Verify a signed cookie
app.get('/verify-signed', (req, res) => {
// Unsigned cookies are in req.cookies
// Signed cookies are in req.signedCookies
if (req.signedCookies.user) {
res.send(`Welcome ${req.signedCookies.user}!`);
} else {
res.send('No verified cookie found');
}
});
  1. Use environment-specific settings:
javascript
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 3600000
};

app.get('/example', (req, res) => {
res.cookie('data', 'value', cookieOptions);
res.send('Cookie set with environment-specific settings');
});
  1. Implement proper cookie rotation for sessions:
javascript
app.post('/refresh-session', (req, res) => {
const oldSessionId = req.signedCookies.session;

if (oldSessionId && sessions[oldSessionId]) {
// Generate new session ID
const newSessionId = generateSessionId();

// Copy session data to new ID
sessions[newSessionId] = {...sessions[oldSessionId]};
sessions[newSessionId].expiresAt = Math.floor(Date.now() / 1000) + (60 * 60);

// Delete old session
delete sessions[oldSessionId];

// Set new cookie
res.cookie('session', newSessionId, cookieOptions);
res.json({ message: 'Session refreshed' });
} else {
res.status(401).json({ error: 'Invalid session' });
}
});
  1. Set reasonable expiration times:
javascript
// Short-lived session cookie (4 hours)
const sessionCookieOptions = {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 4 * 60 * 60 * 1000 // 4 hours in milliseconds
};

// Long-lived preference cookie (30 days)
const preferenceCookieOptions = {
httpOnly: false, // Can be accessed by client JS
secure: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days in milliseconds
};
  1. Use encrypted cookies for sensitive data:
javascript
const crypto = require('crypto');

// Encryption helper functions
function encrypt(text, secretKey) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(secretKey), iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
}

function decrypt(text, secretKey) {
const parts = text.split(':');
const iv = Buffer.from(parts[0], 'hex');
const encryptedText = Buffer.from(parts[1], 'hex');
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(secretKey), iv);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}

// Example usage
const SECRET_KEY = '12345678901234567890123456789012'; // 32 bytes

app.get('/set-encrypted', (req, res) => {
const userData = JSON.stringify({
userId: 42,
role: 'admin'
});

const encryptedData = encrypt(userData, SECRET_KEY);

res.cookie('userInfo', encryptedData, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});

res.send('Encrypted cookie set');
});

app.get('/read-encrypted', (req, res) => {
if (!req.cookies.userInfo) {
return res.status(401).send('No cookie found');
}

try {
const decryptedData = decrypt(req.cookies.userInfo, SECRET_KEY);
const userData = JSON.parse(decryptedData);

res.json({
message: 'Decrypted cookie data',
userData
});
} catch (error) {
res.status(400).send('Invalid cookie data');
}
});

Summary

Implementing secure cookie practices is essential for protecting user data and preventing various attacks in Express.js applications. By leveraging attributes like HttpOnly, Secure, and SameSite, along with techniques such as cookie signing, encryption, and CSRF protection, you can significantly enhance your application's security posture.

Key takeaways:

  • Always use HttpOnly and Secure flags for sensitive cookies
  • Implement SameSite restrictions to prevent CSRF attacks
  • Sign cookies to ensure data integrity
  • Consider cookie prefixing for additional security
  • Encrypt sensitive data stored in cookies
  • Implement proper session management with secure cookie rotation
  • Use appropriate expiration times based on cookie purpose

Additional Resources and Exercises

Resources

Exercises

  1. Basic Cookie Security: Modify the provided cookie examples to implement all security attributes correctly for a production environment.

  2. Session Management: Extend the authentication example to include functionality for:

    • Password reset tokens
    • "Remember me" functionality with longer-lived secure cookies
    • Account lockout after failed login attempts
  3. Cookie Encryption: Create a middleware that automatically encrypts and decrypts specific cookies, making it easy to use throughout your application.

  4. CSRF Protection: Implement CSRF protection for a simple form submission flow, including token generation and validation.

  5. Security Headers: Research and implement additional security headers (using Helmet.js) that complement cookie security measures.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)