CWE-614: Sensitive Cookie in HTTPS Session Without 'Secure' Attribute - JavaScript/Node.js
Overview
A cookie set without the secure attribute is sent on plain HTTP requests as well as HTTPS ones. When that cookie carries a session ID, an authentication token, a CSRF token or a user identifier, a network observer or a man-in-the-middle reads it out of a single plaintext request and replays it as the signed-in user. In JavaScript/Node.js the attribute is a per-call option on each cookie API, so every framework below needs it set explicitly.
Common JavaScript Vulnerability Scenarios:
- Express cookies without
secure: true - Fastify cookies missing
secureattribute - Next.js API routes with insecure cookies
- Custom session management without proper flags
- OAuth tokens in insecure cookies
- Remember-me cookies transmitted over HTTP
JavaScript/Node.js Framework Cookie Security:
- Express:
res.cookie('name', 'value', { secure: true, httpOnly: true, sameSite: 'strict' }) - Fastify:
reply.setCookie('name', 'value', { secure: true, httpOnly: true, sameSite: 'strict' }) - Next.js:
res.setHeader('Set-Cookie', serialize('name', 'value', { secure: true, httpOnly: true, sameSite: 'strict' })) - NestJS:
@Res({ passthrough: true }) res: Response; res.cookie('name', 'value', { secure: true, httpOnly: true, sameSite: 'strict' })
Primary Defence: Set secure: true on every cookie containing sensitive data, and enforce HTTPS in production. secure is the fix for this finding and has no legitimate exception on an HTTPS site. httpOnly: true belongs on any cookie no page script needs to read, which is nearly all of them. SameSite is chosen per flow, not set to Strict by default: use Strict only where nothing legitimate navigates in from another site, and Lax for OAuth/SSO callbacks and ordinary inbound links.
Common Vulnerable Patterns
Express Cookie Without Secure Flag
// VULNERABLE - Session cookie without secure flag
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.post('/login', (req, res) => {
const { username, password } = req.body;
if (authenticateUser(username, password)) {
const sessionToken = crypto.randomBytes(32).toString('base64url');
// VULNERABLE - Missing secure: true
res.cookie('session_id', sessionToken, {
httpOnly: true, // Good, but not enough
maxAge: 3600000 // 1 hour
});
res.json({ status: 'logged_in' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
function authenticateUser(username, password) {
// Authentication logic
return true;
}
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Why this is vulnerable:
- Without
secure, the browser attachessession_idto any plaintext request to the host, where a network observer or man-in-the-middle can read it. - The captured token is the session: replaying it logs the attacker in as that user.
httpOnlykeeps page scripts out of the cookie and does nothing about the network.
Express Session Without Secure Configuration
// VULNERABLE - express-session without secure cookies
const express = require('express');
const session = require('express-session');
const crypto = require('crypto');
const app = express();
// VULNERABLE - Session configuration missing secure flag
app.use(session({
secret: crypto.randomBytes(64).toString('hex'),
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
// Missing: secure: true
// Missing: sameSite: 'strict'
}
}));
app.post('/login', (req, res) => {
const { username, password } = req.body;
if (authenticateUser(username, password)) {
// VULNERABLE - Session cookie sent without secure flag
req.session.userId = getUserId(username);
req.session.username = username;
res.json({ status: 'logged_in' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
function authenticateUser(username, password) {
return true;
}
function getUserId(username) {
return 123;
}
app.listen(3000);
Why this is vulnerable:
- The
cookieblock omitssecure, so express-session issues the session ID on plain HTTP as readily as on HTTPS and anyone on the network path can lift it. - It also omits
sameSite, so the browser attaches the session cookie to cross-site requests and CSRF is possible as well.
Fastify Cookie Without Secure Flag
// VULNERABLE - Fastify application with insecure cookies
const fastify = require('fastify')({ logger: true });
const fastifyCookie = require('@fastify/cookie');
const crypto = require('crypto');
fastify.register(fastifyCookie);
fastify.post('/login', async (request, reply) => {
const { username, password } = request.body;
if (authenticateUser(username, password)) {
const sessionToken = crypto.randomBytes(32).toString('base64url');
// VULNERABLE - Missing secure option
reply.setCookie('session_id', sessionToken, {
httpOnly: true,
maxAge: 3600, // 1 hour in seconds
path: '/'
// Missing: secure: true
// Missing: sameSite: 'strict'
});
return { status: 'logged_in' };
}
reply.code(401);
return { error: 'Invalid credentials' };
});
function authenticateUser(username, password) {
return true;
}
fastify.listen({ port: 3000 }, (err) => {
if (err) throw err;
});
Why this is vulnerable:
setCookieis called withoutsecure, so the authentication token insession_idgoes out over plain HTTP and is readable on the network path.sameSiteis missing too, leaving the cookie attached to cross-site requests.
Next.js API Route Without Secure Cookie
// VULNERABLE - Next.js API route with insecure cookies
// pages/api/login.js
import { serialize } from 'cookie';
import crypto from 'crypto';
export default async function handler(req, res) {
if (req.method === 'POST') {
const { username, password } = req.body;
if (await authenticateUser(username, password)) {
const sessionToken = crypto.randomBytes(32).toString('base64url');
// VULNERABLE - Cookie without secure flag
const cookie = serialize('session_token', sessionToken, {
httpOnly: true,
maxAge: 3600,
path: '/'
// Missing: secure: true
// Missing: sameSite: 'strict'
});
res.setHeader('Set-Cookie', cookie);
res.status(200).json({ status: 'logged_in' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
} else {
res.status(405).json({ error: 'Method not allowed' });
}
}
async function authenticateUser(username, password) {
return true;
}
Why this is vulnerable:
- The options passed to
serializeomitsecure, so theSet-Cookieheader the route writes permits the session token on plain HTTP. - They omit
sameSiteas well, so the cookie is attached to cross-site requests to the API route.
Remember-Me Cookie Without Secure Flag
// VULNERABLE - Remember-me functionality with insecure cookie
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.post('/login', async (req, res) => {
const { username, password, rememberMe } = req.body;
if (await authenticateUser(username, password)) {
const sessionToken = crypto.randomBytes(32).toString('base64url');
// Session cookie (also vulnerable but short-lived)
res.cookie('session_id', sessionToken, {
httpOnly: true,
maxAge: 3600000 // 1 hour
// Missing: secure: true
});
if (rememberMe) {
const rememberToken = crypto.randomBytes(64).toString('base64url');
// VULNERABLE - Long-lived remember-me cookie without secure flag
res.cookie('remember_me', rememberToken, {
httpOnly: true,
maxAge: 30 * 24 * 3600000 // 30 days - VERY vulnerable!
// Missing: secure: true
// Missing: sameSite: 'strict'
});
// Store token in database
await storeRememberToken(username, rememberToken);
}
res.json({ status: 'logged_in' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
async function authenticateUser(username, password) {
return true;
}
async function storeRememberToken(username, token) {
// Database storage
}
app.listen(3000);
Why this is vulnerable:
- Neither cookie sets
secure, so both travel on plain HTTP requests to the host. - The session cookie exposes one hour of access;
remember_mecarries a 30-day credential, so a single plaintext request anywhere in that window hands an attacker a token that logs in as the user until it expires.
JWT Cookie Without Proper Security
// VULNERABLE - JWT stored in cookie without security flags
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET || 'default-secret';
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
if (authenticateUser(username, password)) {
const token = jwt.sign(
{ username, iat: Date.now() },
JWT_SECRET,
{ expiresIn: '1h' }
);
// VULNERABLE - Multiple issues
res.cookie('auth_token', token, {
maxAge: 3600000
// Missing: secure: true
// Missing: httpOnly: true
// Missing: sameSite: 'strict'
});
res.json({ status: 'success' });
} else {
res.status(401).json({ error: 'Authentication failed' });
}
});
function authenticateUser(username, password) {
return true;
}
app.listen(3000);
Why this is vulnerable:
- No
secure: trueflag - No
httpOnly: true(XSS vulnerable) - No
sameSiteattribute (CSRF vulnerable)
OAuth State Cookie Without Security
// VULNERABLE - OAuth state cookie without security flags
const express = require('express');
const crypto = require('crypto');
const app = express();
const OAUTH_CLIENT_ID = process.env.OAUTH_CLIENT_ID;
app.get('/oauth/authorize', (req, res) => {
const state = crypto.randomBytes(32).toString('base64url');
// VULNERABLE - OAuth state cookie without secure flag
res.cookie('oauth_state', state, {
maxAge: 600000 // 10 minutes
// Missing: secure: true
// Missing: httpOnly: true
// Missing: sameSite: 'lax' (for OAuth redirects)
});
const oauthUrl = `https://oauth.provider.com/authorize?` +
`client_id=${OAUTH_CLIENT_ID}&` +
`redirect_uri=https://example.com/oauth/callback&` +
`state=${state}`;
res.redirect(oauthUrl);
});
app.get('/oauth/callback', (req, res) => {
const stateParam = req.query.state;
const stateCookie = req.cookies.oauth_state;
if (stateParam !== stateCookie) {
return res.status(400).json({ error: 'Invalid state' });
}
// Continue OAuth flow
res.json({ status: 'success' });
});
app.listen(3000);
Why this is vulnerable:
- The
statevalue travels in the clear, so a network observer learns it. It is correlation and CSRF data, not an access token - reading it does not by itself steal an OAuth token. - Knowing
statelets an attacker forge a callback the application accepts, which is login CSRF: the victim's browser is silently signed in to the attacker's account, and anything the victim then does lands there. - It also enables authorization-code injection, where an attacker replays their
own code against the victim's session with a
statethe check now approves. - Without
httpOnlyany XSS can read it too, and withoutsecurethe value is exposed on every plaintext request the browser makes to the host.
NestJS Cookie Without Security Flags
// VULNERABLE - NestJS application with insecure cookies
import { Controller, Post, Body, Res } from '@nestjs/common';
import { Response } from 'express';
import * as crypto from 'crypto';
@Controller('api/auth')
export class AuthController {
@Post('login')
async login(
@Body() body: { username: string, password: string },
@Res({ passthrough: true }) res: Response
) {
if (await this.authenticateUser(body.username, body.password)) {
const sessionToken = crypto.randomBytes(32).toString('base64url');
// VULNERABLE - Cookie without security flags
res.cookie('session_token', sessionToken, {
httpOnly: true,
maxAge: 3600000 // 1 hour
// Missing: secure: true
// Missing: sameSite: 'strict'
});
return { status: 'logged_in' };
}
throw new Error('Invalid credentials');
}
private async authenticateUser(username: string, password: string): Promise<boolean> {
return true;
}
}
Why this is vulnerable:
- The cookie sets
httpOnlybut notsecure, so the session token still goes out on plain HTTP and a network observer who captures it can replay the session. - A modern framework does not supply these flags for you:
res.cookiehere is Express's, and each attribute has to be passed explicitly.
Secure Patterns
Express Cookie With All Security Flags
// SECURE - Express cookie with proper security flags
const express = require('express');
const crypto = require('crypto');
const https = require('https');
const fs = require('fs');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// SECURE - Trust proxy if behind reverse proxy (Nginx, etc.)
app.set('trust proxy', 1);
app.post('/login', (req, res) => {
const { username, password } = req.body;
if (authenticateUser(username, password)) {
const sessionToken = crypto.randomBytes(32).toString('base64url');
// SECURE - All critical security flags set
res.cookie('session_id', sessionToken, {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
sameSite: 'strict', // CSRF protection
maxAge: 3600000, // 1 hour
path: '/'
});
res.json({ status: 'logged_in' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
app.post('/logout', (req, res) => {
// SECURE - Clear cookie with same settings
res.clearCookie('session_id', {
secure: true,
httpOnly: true,
sameSite: 'strict',
path: '/'
});
res.json({ status: 'logged_out' });
});
function authenticateUser(username, password) {
// Secure authentication logic
return true;
}
// SECURE - Run with HTTPS in production
if (process.env.NODE_ENV === 'production') {
const httpsOptions = {
key: fs.readFileSync(process.env.SSL_KEY_PATH),
cert: fs.readFileSync(process.env.SSL_CERT_PATH)
};
https.createServer(httpsOptions, app).listen(443, () => {
console.log('HTTPS Server running on port 443');
});
} else {
// Development only
app.listen(3000, () => {
console.log('Development server on port 3000');
});
}
Why this works:
- Secure + HttpOnly + SameSite prevent HTTP leakage, reduce JavaScript cookie theft, and add CSRF defense-in-depth.
- HTTPS +
trust proxyensure secure cookies work behind TLS termination. - Short lifetimes and proper logout reduce exposure.
Express Session With Secure Configuration
// SECURE - express-session with proper security
const express = require('express');
const session = require('express-session');
const { RedisStore } = require('connect-redis'); // named export since v8
const { createClient } = require('redis');
const app = express();
app.use(express.json());
// SECURE - Trust proxy for secure cookies behind reverse proxy
app.set('trust proxy', 1);
// Fail to boot rather than falling back to a per-process random secret: a
// generated fallback signs cookies no other instance can verify, and
// invalidates every session on restart
function requireSecret(name) {
const value = process.env[name];
if (!value) throw new Error(`${name} is not set`);
return value;
}
// Create Redis client for session storage
const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
});
redisClient.connect().catch(console.error);
// SECURE - Session configuration with all security flags
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: requireSecret('SESSION_SECRET'),
resave: false,
saveUninitialized: false,
name: 'sessionId', // Custom name (don't use default 'connect.sid')
cookie: {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
sameSite: 'strict', // CSRF protection
maxAge: 3600000 // 1 hour
}
}));
app.post('/login', (req, res) => {
const { username, password } = req.body;
if (authenticateUser(username, password)) {
// Regenerate session ID to prevent session fixation
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'Session error' });
}
// SECURE - Session cookie automatically uses secure settings
req.session.userId = getUserId(username);
req.session.username = username;
res.json({ status: 'logged_in' });
});
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('sessionId', {
secure: true,
httpOnly: true,
sameSite: 'strict'
});
res.json({ status: 'logged_out' });
});
});
function authenticateUser(username, password) {
return true;
}
function getUserId(username) {
return 123;
}
// SECURE - HTTPS server
const https = require('https');
const fs = require('fs');
if (process.env.NODE_ENV === 'production') {
const httpsOptions = {
key: fs.readFileSync(process.env.SSL_KEY_PATH),
cert: fs.readFileSync(process.env.SSL_CERT_PATH)
};
https.createServer(httpsOptions, app).listen(443);
}
Why this works:
- Secure cookies + server-side sessions keep data off the client.
- Regenerating session IDs blocks fixation attacks.
- Strong secrets, sane expiry, and logout cleanup limit exposure.
Fastify With Secure Cookies
// SECURE - Fastify with proper cookie security
const fastify = require('fastify')({
logger: true,
https: {
key: require('fs').readFileSync(process.env.SSL_KEY_PATH),
cert: require('fs').readFileSync(process.env.SSL_CERT_PATH)
}
});
const fastifyCookie = require('@fastify/cookie');
const fastifySession = require('@fastify/session');
const crypto = require('crypto');
fastify.register(fastifyCookie);
// SECURE - Session with all security flags
if (!process.env.SESSION_SECRET) {
throw new Error('SESSION_SECRET is not set'); // never generate a fallback
}
fastify.register(fastifySession, {
secret: process.env.SESSION_SECRET,
cookieName: 'sessionId',
cookie: {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
sameSite: 'strict', // CSRF protection
maxAge: 3600000 // 1 hour
},
saveUninitialized: false
});
fastify.post('/login', async (request, reply) => {
const { username, password } = request.body;
if (authenticateUser(username, password)) {
const sessionToken = crypto.randomBytes(32).toString('base64url');
// SECURE - Cookie with all security flags
reply.setCookie('session_id', sessionToken, {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
sameSite: 'strict', // CSRF protection
maxAge: 3600, // 1 hour (in seconds)
path: '/'
});
// Also set session data
request.session.set('userId', getUserId(username));
request.session.set('username', username);
return { status: 'logged_in' };
}
reply.code(401);
return { error: 'Invalid credentials' };
});
fastify.post('/logout', async (request, reply) => {
// Clear cookie
reply.clearCookie('session_id', {
secure: true,
httpOnly: true,
sameSite: 'strict',
path: '/'
});
// Destroy session
request.session.destroy();
return { status: 'logged_out' };
});
function authenticateUser(username, password) {
return true;
}
function getUserId(username) {
return 123;
}
// SECURE - Listen on HTTPS
fastify.listen({ port: 443, host: '0.0.0.0' }, (err) => {
if (err) throw err;
});
Why this works:
- HTTPS-only server + secure cookies prevent HTTP leakage.
- Manual and session cookies share Secure/HttpOnly/SameSite flags.
- Session hygiene (no empty sessions, logout cleanup) reduces exposure.
Next.js API Route With Secure Cookies
// SECURE - Next.js API route with proper cookie security
// pages/api/login.js
import { serialize } from 'cookie';
import crypto from 'crypto';
export default async function handler(req, res) {
if (req.method === 'POST') {
const { username, password } = req.body;
if (await authenticateUser(username, password)) {
const sessionToken = crypto.randomBytes(32).toString('base64url');
// SECURE - Cookie with all security flags
const cookie = serialize('session_token', sessionToken, {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
sameSite: 'strict', // CSRF protection
maxAge: 3600, // 1 hour
path: '/'
});
res.setHeader('Set-Cookie', cookie);
res.status(200).json({ status: 'logged_in' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
} else {
res.status(405).json({ error: 'Method not allowed' });
}
}
async function authenticateUser(username, password) {
// Secure authentication
return true;
}
// pages/api/logout.js
import { serialize } from 'cookie';
export default async function handler(req, res) {
if (req.method === 'POST') {
// SECURE - Delete cookie with same security settings
const cookie = serialize('session_token', '', {
secure: true,
httpOnly: true,
sameSite: 'strict',
maxAge: 0,
path: '/'
});
res.setHeader('Set-Cookie', cookie);
res.status(200).json({ status: 'logged_out' });
} else {
res.status(405).json({ error: 'Method not allowed' });
}
}
// SECURE - next.config.js: force HTTPS in production
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
}
]
}
];
}
};
Why this works:
- Secure/HttpOnly/SameSite flags are applied at the header level.
- Logout clears cookies with matching attributes.
- HSTS enforces HTTPS for all routes and subdomains.
Secure Remember-Me Implementation
// SECURE - Remember-me with proper security
const express = require('express');
const crypto = require('crypto');
const cookieParser = require('cookie-parser');
const app = express();
app.use(express.json());
app.use(cookieParser()); // without this req.cookies is undefined
app.set('trust proxy', 1);
class RememberMeService {
// selector identifies the row, validator authenticates it. Splitting them
// makes verification a single indexed lookup plus one comparison.
static generateToken() {
return {
selector: crypto.randomBytes(16).toString('base64url'),
validator: crypto.randomBytes(32).toString('base64url')
};
}
static hashValidator(validator) {
// The validator is 32 random bytes, not a password: it has full entropy and
// is not guessable, so a fast hash is correct here. bcrypt would be wrong -
// it buys nothing against an unguessable value and costs ~250ms per check.
return crypto.createHash('sha256').update(validator).digest('hex');
}
static async storeToken(username, { selector, validator }) {
await db.rememberTokens.insert({
username,
selector,
validatorHash: this.hashValidator(validator),
createdAt: new Date(),
expiresAt: new Date(Date.now() + 30 * 24 * 3600000)
});
}
static async verifyToken(cookieValue) {
const [selector, validator] = String(cookieValue).split(':');
if (!selector || !validator) return null;
const record = await db.rememberTokens.findOne({
selector,
expiresAt: { $gt: new Date() }
});
if (!record) return null;
// Constant-time compare so the lookup does not leak the stored hash
const provided = Buffer.from(this.hashValidator(validator), 'hex');
const stored = Buffer.from(record.validatorHash, 'hex');
if (provided.length !== stored.length) return null;
if (!crypto.timingSafeEqual(provided, stored)) return null;
return record.username;
}
}
app.post('/login', async (req, res) => {
const { username, password, rememberMe } = req.body;
if (await authenticateUser(username, password)) {
const sessionToken = crypto.randomBytes(32).toString('base64url');
// SECURE - Session cookie with all flags
res.cookie('session_id', sessionToken, {
secure: true,
httpOnly: true,
sameSite: 'strict',
maxAge: 3600000 // 1 hour
});
if (rememberMe) {
const rememberToken = RememberMeService.generateToken();
await RememberMeService.storeToken(username, rememberToken);
// SECURE - Remember-me cookie with all flags
res.cookie('remember_me', `${rememberToken.selector}:${rememberToken.validator}`, {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
sameSite: 'strict', // CSRF protection
maxAge: 30 * 24 * 3600000 // 30 days
});
}
res.json({ status: 'logged_in' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
app.post('/auto-login', async (req, res) => {
const rememberToken = req.cookies.remember_me;
if (!rememberToken) {
return res.status(401).json({ error: 'No remember-me token' });
}
const username = await RememberMeService.verifyToken(rememberToken);
if (username) {
const sessionToken = crypto.randomBytes(32).toString('base64url');
// SECURE - Create new session
res.cookie('session_id', sessionToken, {
secure: true,
httpOnly: true,
sameSite: 'strict',
maxAge: 3600000
});
res.json({ status: 'auto_logged_in', username });
} else {
res.status(401).json({ error: 'Invalid token' });
}
});
async function authenticateUser(username, password) {
return true;
}
// Mock database
const db = {
rememberTokens: {
insert: async (doc) => {},
findOne: async (query) => null
}
};
module.exports = app;
Why this works:
- Secure/HttpOnly/SameSite reduce transport, JavaScript access, and cross-site request risk for both session and remember-me cookies.
- Tokens are hashed at rest, reducing impact of database compromise.
- Auto-login issues a fresh short-lived session cookie.
The selector/validator split is what makes verification affordable. Storing only a hash of the whole token leaves no indexable column, so the lookup has to compare the presented token against every stored row in turn - with a deliberately slow hash that is hundreds of milliseconds per row, so a few thousand live tokens turn every auto-login into a timeout and give any unauthenticated caller a denial-of-service primitive. The selector is a plain indexed column that finds exactly one row; the validator is the secret, and only it is hashed and compared.
OAuth State Cookie With Security
// SECURE - OAuth state cookie: Secure and HttpOnly, but Lax rather than Strict
const express = require('express');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');
const app = express();
app.use(cookieParser());
app.set('trust proxy', 1);
app.get('/oauth/authorize', (req, res) => {
const state = crypto.randomBytes(32).toString('base64url');
res.cookie('oauth_state', state, {
secure: true, // HTTPS only - the point of this finding
httpOnly: true, // the state is never read by page scripts
sameSite: 'lax', // MUST be lax: the provider redirects back cross-site
maxAge: 600000, // 10 minutes - the flow is short
path: '/oauth' // only sent to the routes that use it
});
res.redirect(
'https://oauth.provider.com/authorize' +
`?client_id=${encodeURIComponent(process.env.OAUTH_CLIENT_ID)}` +
'&redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback' +
'&response_type=code' +
`&state=${state}`
);
});
app.get('/oauth/callback', (req, res) => {
const stateParam = req.query.state;
const stateCookie = req.cookies.oauth_state;
// Clear it first: the state is single-use whether or not it matches
res.clearCookie('oauth_state', { secure: true, httpOnly: true, sameSite: 'lax', path: '/oauth' });
if (!stateParam || !stateCookie || stateParam !== stateCookie) {
return res.status(400).json({ error: 'Invalid state' });
}
res.json({ status: 'success' });
});
Why this works: secure: true and httpOnly: true close the finding - the
state cookie never travels in the clear and no page script can read it.
sameSite: 'lax' is the deliberate part, and it is the one attribute on this
page that must not be 'strict'. The provider returns the user by redirecting
the browser from its own origin to yours, which is a cross-site top-level
navigation; Strict withholds the cookie on exactly that request, so
req.cookies.oauth_state is undefined, the comparison fails, and every login
attempt is rejected with Invalid state. Nothing in the code looks wrong and
the scanner is satisfied either way, so this is a fix that silently breaks
authentication rather than one that fails a test. Lax sends the cookie on
top-level navigations, which is precisely the callback, and still withholds it
from cross-site POSTs and subresource loads.
NestJS With Secure Cookie Configuration
// SECURE - NestJS application with proper cookie security
import { Controller, Post, Body, Res } from '@nestjs/common';
import { Response } from 'express';
import * as crypto from 'crypto';
@Controller('api/auth')
export class SecureAuthController {
@Post('login')
async login(
@Body() body: { username: string, password: string },
@Res({ passthrough: true }) res: Response
) {
if (await this.authenticateUser(body.username, body.password)) {
const sessionToken = crypto.randomBytes(32).toString('base64url');
// SECURE - Cookie with all security flags
res.cookie('session_token', sessionToken, {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
sameSite: 'strict', // CSRF protection
maxAge: 3600000 // 1 hour
});
return { status: 'logged_in' };
}
throw new Error('Invalid credentials');
}
@Post('logout')
async logout(@Res({ passthrough: true }) res: Response) {
res.clearCookie('session_token', {
secure: true,
httpOnly: true,
sameSite: 'strict'
});
return { status: 'logged_out' };
}
private async authenticateUser(username: string, password: string): Promise<boolean> {
return true;
}
}
// SECURE - main.ts NestJS HTTPS configuration
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import * as fs from 'fs';
async function bootstrap() {
const httpsOptions = {
key: fs.readFileSync(process.env.SSL_KEY_PATH),
cert: fs.readFileSync(process.env.SSL_CERT_PATH)
};
// NestExpressApplication, not INestApplication: only the typed variant
// exposes set(), which is the underlying Express instance's method
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
httpsOptions
});
// Enable trust proxy for secure cookies behind a TLS-terminating proxy
app.set('trust proxy', 1);
await app.listen(443);
}
bootstrap();
Why this works:
- HTTPS + Secure cookies prevent HTTP leakage.
- HttpOnly + SameSite reduce JavaScript cookie theft and cross-site request risk.
- Trust proxy keeps
secureworking behind a TLS-terminating proxy, and logout clears the cookie with matching attributes.
Testing
- Sign in over HTTPS and assert the response carries
Set-Cookie: ...; Secure; HttpOnly; SameSite=...for every sensitive cookie, then assert the next authenticated request succeeds.express-sessionwithcookie.secure: truedoes not set the cookie at all on a connection it considers insecure, so behind a misconfigured proxy the correct-looking fix produces no session rather than an insecure one - a rejection test cannot tell the two apart. - Request the same host over plain HTTP after signing in and assert the browser
sends no session or authentication cookie in the request.
Securegoverns what the browser transmits, not what the server writes, so inspecting the response proves nothing. - Complete a full OAuth round trip against the provider and assert the callback
returns success, not
Invalid state.sameSite: 'strict'on the state cookie fails only here, and only against a real cross-site redirect - a same-origin test of the callback route passes. - Run the suite through the real ingress, including the proxy that terminates
TLS, so
trust proxyandX-Forwarded-Protohandling are exercised as deployed.
Common Pitfalls
- Setting
secure: trueinexpress-session's cookie options without callingapp.set('trust proxy', 1)behind a reverse proxy or load balancer that terminates TLS - Express derives whether the connection is secure from the raw socket unless trust proxy is configured, so the session middleware (especially withcookie.secure: 'auto') can silently omit the flag even though the public connection is HTTPS. - Setting
secure: trueon the server-issued session cookie while a separate analytics or tracking cookie is set client-side viadocument.cookie = "..."- a client-set cookie bypasses the server's cookie configuration entirely and needs; Secureappended explicitly in that JavaScript string. - Configuring
secure: truein the main Express session middleware while a WebSocket handshake or a separate real-time endpoint (e.g.,socket.io) issues its own cookie through a different code path that isn't covered by the same middleware chain.