by Aaron Adetunmbi
Day 4: Cookies
Learn about cookie security best practices to protect your web applications from attacks.
Day 4: Session Cookies and CSRF Attacks
The Problem We're Solving
Before we talk about solutions, you need to understand the attack.
Your browser has a fundamental trust model: if you're logged into your bank, and you visit a malicious website in another tab, your browser will automatically send your bank's session cookie to any request that website makes to your bank.
This is by design. It's how websites remember who you are. But it's also how attackers trick you into doing things you didn't intend to do.
This attack is called CSRF: Cross-Site Request Forgery.
How CSRF Actually Works
Let's say you're logged into your bank at bank.com. You have a session cookie in your browser that proves you're authenticated.
Now you visit a malicious website at evil.com (maybe through a phishing link or a legitimate site that got hacked).
The attacker's webpage contains this hidden code:
<img src="https://bank.com/api/transfer?to=attacker&amount=10000" />
When your browser loads this page, it automatically sends your bank's session cookie along with that request. Your bank receives it, sees a valid session cookie, and transfers $10,000 to the attacker.
You never clicked anything. You didn't know it happened. The attacker just used your authenticated session against you.
Why This Matters
CSRF attacks work because:
- Your browser automatically sends cookies to ANY request to that domain, regardless of where the request came from.
- The attacker doesn't need your password. They just need you to visit their page while you're logged in.
- The bank can't tell the difference between a legitimate request you made and a malicious one the attacker forced.
This is a real attack. It happens. Companies have lost millions because of CSRF vulnerabilities.
The Real-World Example: How Linus Tech Tips Got Hacked
In 2023, an employee at Linus Media Group downloaded a PDF file. The file contained malware that extracted their YouTube session cookie.
The attacker now had a valid session token. They didn't need the employee's password. They didn't need to bypass 2FA because 2FA only validates during login—once a session is established and you have a valid session cookie, you're authenticated. 2FA isn't re-checked on every request.
They just replayed the cookie.
With that cookie, they logged into YouTube as if they were the employee. They:
- Accessed the channel admin panel
- Livestreamed a cryptocurrency scam (making thousands in minutes)
- Deleted years of videos
- Renamed the channel
The damage was done in hours. Recovery took weeks.
Why did this happen? The session cookie had no protection. It was readable by malware. It was sent over any channel. It never expired. It was the master key, and once stolen, it unlocked everything.
The Solution: SameSite Cookies
The modern fix for CSRF is the SameSite attribute on cookies. It controls whether a cookie is sent on cross-site requests.
There are three values:
Browser default: If you don't set SameSite, modern browsers treat the cookie as Lax by default. This is good news—it means you get CSRF protection by default, even if you forget to set it.
SameSite=Strict
The cookie is ONLY sent when you're on the same website where you logged in.
res.cookie('sessionId', token, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 3600000 // 1 hour
});
What this prevents: The attacker's malicious webpage cannot trigger a request to your bank. The cookie won't be sent. CSRF is impossible.
Trade-off: If you click a link in an email that goes to your bank, the cookie won't be sent on the first request. You'll be logged out temporarily and need to log back in. Worse UX.
Use this for: High-security operations like payments, account changes, admin panels.
SameSite=Lax
The cookie is sent on same-site requests AND on top-level navigation (like clicking a link or a form submission that you explicitly triggered).
res.cookie('sessionId', token, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
What this prevents: The attacker's malicious webpage cannot trigger a request to your bank using <img> or AJAX calls. If they try to submit a form on their malicious site to transfer money from your bank, the cookie won't be sent because it's not a top-level navigation YOU initiated.
What it allows: If you click a legitimate link in an email that goes to your bank, the cookie IS sent because you explicitly navigated there. If the bank redirects you to a login form via GET, the cookie is sent.
Important caveat: When a cookie is first set, there's a 2-minute grace period where Lax cookies ARE sent with POST requests even without top-level navigation. This helps with login flows. After 2 minutes, the protection kicks in fully. This means Lax has a brief vulnerability window right after login.
Use this for: General authentication. Standard sessions where you need email links and password reset flows to work. The UX is better than Strict, and CSRF is blocked for the vast majority of attacks.
SameSite=None
The cookie is sent on ALL requests, including cross-site.
res.cookie('sessionId', token, {
httpOnly: true,
secure: true,
sameSite: 'None',
maxAge: 3600000
});
What this does: The attacker's malicious webpage CAN trigger a request to your bank. The cookie WILL be sent. CSRF attacks work perfectly.
When you'd use this: Almost never. Only if your API is explicitly designed to be called from third-party websites with credentials. This is extremely rare and requires you to have other defenses.
Real example of why this is dangerous: Langflow AI (2025) made this mistake. They set sameSite: 'None' on their refresh token cookie AND had overly permissive CORS. An attacker crafted a malicious webpage. When a Langflow user visited it, their browser sent the refresh token cookie automatically. The attacker stole it, gained account access, and executed arbitrary code. CVSS 9.4 (critical).
The Other Piece: httpOnly
sameSite solves CSRF. But there's another attack: XSS (Cross-Site Scripting).
If an attacker can inject JavaScript into your page (through a vulnerable comment field, user input, etc.), they can steal your cookies if they're readable by JavaScript.
The solution: httpOnly: true
res.cookie('sessionId', token, {
httpOnly: true, // JavaScript cannot read this cookie
secure: true,
sameSite: 'Strict',
maxAge: 3600000
});
With httpOnly: true:
- The cookie is automatically sent on requests (the browser handles it)
- JavaScript cannot access it (even your own code, even malicious code injected via XSS)
- Malware on the user's device cannot read it (though it might still intercept network traffic if HTTPS isn't enforced)
The Complete Defense
Here's what a properly secured session cookie looks like:
res.cookie('sessionId', token, {
httpOnly: true, // Protects against XSS attacks
secure: true, // Only sent over HTTPS
sameSite: 'Lax', // Protects against CSRF, allows email links
maxAge: 3600000, // 1 hour expiry
path: '/',
domain: 'yourdomain.com'
});
Breaking it down:
httpOnly: true: Malware can't steal it via JavaScriptsecure: true: Only sent over HTTPS, not HTTP (no man-in-the-middle interception)sameSite: 'Lax': Attackers can't trigger cross-site requests with it (has a 2-minute grace period after setting)maxAge: 3600000: Even if stolen, it expires in 1 hourpath: '/': Available to your entire applicationdomain: 'yourdomain.com': Sent to this domain AND its subdomains (like api.yourdomain.com). Omit this to scope it to the exact domain only
Each flag solves a different attack. Together, they form a complete defense.
What This Prevents
- XSS attacks: Malicious JavaScript can't read the cookie
- CSRF attacks: Malicious websites can't use the cookie to make unwanted requests
- Man-in-the-middle attacks: The cookie is only sent over HTTPS
- Session replay attacks: The cookie expires, making stolen old cookies useless
- Subdomain attacks: The cookie is scoped to your exact domain.
When to Use What
Use sameSite: 'Strict' for:
- Admin panels
- Payment pages
- Account settings
- Sensitive operations
Use sameSite: 'Lax' for:
- General authentication
- Login flows that include email links
- Standard web applications
Use sameSite: 'None' for:
- Third-party APIs (rare, requires careful consideration)
- Embedded content (chat widgets, payments embedded on other sites)
On Logout: Invalidate Immediately
When a user logs out, don't just clear the cookie. Invalidate the session server-side immediately:
app.post('/logout', (req, res) => {
// Delete the session from your session store (Redis, database, etc.)
if (req.session) {
req.session.destroy((err) => {
if (err) console.error('Session destruction error:', err);
});
}
// Clear the cookie
res.clearCookie('sessionId', {
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/'
});
res.json({ success: true });
});
Why both? Because a cookie is just a key. Even if you clear it from the browser, if the session still exists on the server, someone with a copy of that session token can use it. Invalidate server-side first.
Key Takeaway: Session cookies are powerful. They're also dangerous if misconfigured. sameSite, httpOnly, secure, and short expiry times work together to defend against CSRF, XSS, interception, and session replay attacks. Get all of them right, and your sessions are locked down.
Next: We'll talk about CORS and controlling which domains can access your API in the first place.