Skip to content

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 secure attribute
  • 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

// 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 attaches session_id to 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. httpOnly keeps 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 cookie block omits secure, 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.
// 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:

  • setCookie is called without secure, so the authentication token in session_id goes out over plain HTTP and is readable on the network path.
  • sameSite is missing too, leaving the cookie attached to cross-site requests.
// 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 serialize omit secure, so the Set-Cookie header the route writes permits the session token on plain HTTP.
  • They omit sameSite as well, so the cookie is attached to cross-site requests to the API route.
// 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_me carries 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.
// 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: true flag
  • No httpOnly: true (XSS vulnerable)
  • No sameSite attribute (CSRF vulnerable)
// 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 state value 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 state lets 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 state the check now approves.
  • Without httpOnly any XSS can read it too, and without secure the value is exposed on every plaintext request the browser makes to the host.
// 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 httpOnly but not secure, 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.cookie here is Express's, and each attribute has to be passed explicitly.

Secure Patterns

// 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 proxy ensure 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.

// 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.

// 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 secure working 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-session with cookie.secure: true does 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. Secure governs 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 proxy and X-Forwarded-Proto handling are exercised as deployed.

Common Pitfalls

  • Setting secure: true in express-session's cookie options without calling app.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 with cookie.secure: 'auto') can silently omit the flag even though the public connection is HTTPS.
  • Setting secure: true on the server-issued session cookie while a separate analytics or tracking cookie is set client-side via document.cookie = "..." - a client-set cookie bypasses the server's cookie configuration entirely and needs ; Secure appended explicitly in that JavaScript string.
  • Configuring secure: true in 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.

Additional Resources