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.
Setting Up Cookie Handling in Express
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:
npm install cookie-parser
Basic Setup
Here's how to integrate the cookie parser into your Express application:
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');
});
Essential Cookie Security Attributes
The Secure
Flag
The Secure
flag ensures that cookies are only sent over HTTPS connections, preventing them from being transmitted over unencrypted HTTP connections.
// 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:
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:
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:
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:
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');
});
Putting It All Together: A Secure Cookie
Here's an example of setting a cookie with all security attributes properly configured:
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:
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');
});
Common Cookie Security Vulnerabilities and Mitigations
Cross-Site Scripting (XSS) Protection
Beyond using HttpOnly
cookies, consider implementing Content Security Policy headers:
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:
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:
<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>
Cookie Prefixing
Modern browsers support cookie prefixes that enforce security attributes:
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:
// 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');
}
});
Best Practices for Cookie Security
- Use environment-specific settings:
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');
});
- Implement proper cookie rotation for sessions:
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' });
}
});
- Set reasonable expiration times:
// 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
};
- Use encrypted cookies for sensitive data:
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
andSecure
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
-
Basic Cookie Security: Modify the provided cookie examples to implement all security attributes correctly for a production environment.
-
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
-
Cookie Encryption: Create a middleware that automatically encrypts and decrypts specific cookies, making it easy to use throughout your application.
-
CSRF Protection: Implement CSRF protection for a simple form submission flow, including token generation and validation.
-
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! :)