DEV Community

Cover image for The Complete Guide to HTTP Cookies: What Every Web Developer Must Know
Nikita Dmitriev
Nikita Dmitriev

Posted on

The Complete Guide to HTTP Cookies: What Every Web Developer Must Know

Introduction

You're building your first authentication system. You've got your login form working, your server validates credentials perfectly, but somehow users keep getting logged out randomly. Or worse, their authentication works on some pages but mysteriously fails on others. Sound familiar?

Welcome to the wonderful, weird, and occasionally infuriating world of HTTP cookies. They're deceptively simple on the surface - just key-value pairs, right? but beneath that simplicity lies a sophisticated security system that trips up even experienced developers.

Today, we're going to demystify cookies completely. Not just the "what," but more importantly, the "why" behind every single behavior. By the end of this article, you'll understand cookies so deeply that authentication bugs will practically debug themselves.

The Cookie Origin Story: Why Do We Even Need Them?

Here's a mind-bending fact: HTTP is stateless. Every single request your browser makes is like meeting someone for the first time. The server has absolutely no memory of who you are or what you did five seconds ago.

Imagine if every time you clicked a link on Amazon, you had to log in again. Click "Add to Cart"? Login. View your cart? Login. Check out? You get the idea. This would be the reality without cookies.

Cookies were invented in 1994 by Lou Montulli at Netscape (yes, that Netscape) to solve this exact problem. He needed a way for servers to recognize returning visitors without maintaining massive server-side session tables for every user on the internet. The solution? Let the client (browser) hold onto a small piece of identifying data and send it back with every request.

Let me show you this dance between browser and server:

cookies

Anatomy of a Cookie: More Than Just a Name and Value

When you first learn about cookies, you might think they're just simple key-value pairs, like username=alice. But that's like saying a car is just wheels and an engine. The magic and the complexity comes from all the additional attributes that control exactly when, where, and how that cookie gets sent.

Let's dissect a real cookie and understand what each part does:

Set-Cookie: sessionId=abc123; Domain=.example.com; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=86400
Enter fullscreen mode Exit fullscreen mode

Think of these attributes as security guards with very specific instructions about when to let the cookie through. Each guard has a different job, and they all work together to prevent your authentication tokens from ending up in the wrong hands.

The HttpOnly Guard: "No JavaScript Allowed!"

The HttpOnly attribute is like putting your cookie in a locked safe that only the browser itself can open. When this flag is set, JavaScript code cannot access the cookie through document.cookie.

Why does this matter? Imagine an attacker manages to inject some malicious JavaScript into your site through an XSS vulnerability. Without HttpOnly, they could simply run document.cookie and steal all your users' session tokens. With HttpOnly, that attack vector is completely blocked.

// Without HttpOnly
document.cookie = "regularCookie=stolen";
console.log(document.cookie); // "regularCookie=stolen"

// With HttpOnly
// Server sets: Set-Cookie: authToken=secret; HttpOnly
console.log(document.cookie); // authToken is invisible!
// But the browser still sends it with HTTP requests
Enter fullscreen mode Exit fullscreen mode

The tradeoff: You can't read or manipulate these cookies in your client-side code. This is usually fine for authentication tokens, but it means you need a different approach for things like theme preferences that your JavaScript needs to read.

The Secure Guard: "HTTPS Only, Please!"

The Secure flag is straightforward but critical. It tells the browser to only send this cookie over encrypted HTTPS connections. Without it, your cookie could be intercepted by anyone on the same coffee shop WiFi as your user.

Here's the interesting part: browsers make an exception for localhost during development. This is why your auth cookies work locally even without HTTPS. But the moment you deploy to production without HTTPS, those Secure cookies simply won't be sent.

// In production
// User visits: http://example.com (not HTTPS!)
// Cookie with Secure flag: NOT SENT ❌
// Cookie without Secure flag: SENT ✅ (but vulnerable!)

// On localhost (special exception)
// User visits: http://localhost:3000
// Cookie with Secure flag: SENT ✅ (browser makes exception)
Enter fullscreen mode Exit fullscreen mode

Understanding CSRF Attacks

Cross-Site Request Forgery (CSRF) represents one of the most elegant and dangerous web vulnerabilities. To truly understand how the SameSite cookie attribute protects us, we need to first understand the attack it's defending against and why it was such a significant problem for web security.

The Anatomy of a CSRF Attack

The fundamental issue that makes CSRF possible is actually a feature, not a bug. Browsers automatically include cookies with every request to a domain, regardless of where that request originates. This automatic cookie inclusion is what makes seamless authentication possible across multiple pages of a website. However, this same convenience becomes a massive vulnerability when exploited by attackers.

Think about how authentication typically works on the web. When you log into a website, the server sets a session cookie in your browser. From that point forward, every request you make to that domain automatically includes that cookie, proving you're authenticated. The server doesn't need to ask who you are with each click because your browser helpfully attaches your identity to every request. This system works beautifully until someone figures out how to make your browser send requests you didn't intend to make.

Let me walk you through exactly how a CSRF attack unfolds. Consider Sarah, who's logged into her bank at bank.com with a valid session cookie. While still logged in, she visits another website that appears innocent but contains malicious code. This is where the attack begins.

CSRF Attack

The truly insidious part of this attack is that everything appears legitimate from the bank's perspective. The request arrives with a valid session cookie, so the bank processes it. Sarah's browser did exactly what it was designed to do, which is include the relevant cookies with the request to bank.com. The attack exploits the very feature that makes the web convenient to use.

Here's what the attacker's malicious page might look like. Notice how simple it is to execute this attack:

<!-- evil-cats.com/cute-cats.html -->
<!DOCTYPE html>
<html>
<head>
    <title>Adorable Cats!</title>
</head>
<body>
    <h1>Look at these cute cats!</h1>
    <img src="cat.jpg" alt="Cute cat">

    <!-- The evil part - completely invisible to Sarah -->
    <form id="evil-form" action="https://bank.com/transfer" method="POST" style="display: none;">
        <input type="hidden" name="recipient" value="attacker-account">
        <input type="hidden" name="amount" value="1000">
        <input type="hidden" name="currency" value="USD">
    </form>

    <script>
        // Submit the form automatically when the page loads
        // Sarah won't even see it happen
        window.onload = function() {
            document.getElementById('evil-form').submit();
        };
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

When Sarah visits this page, her browser faithfully sends her bank.com authentication cookie along with the forged transfer request. The bank cannot distinguish between this request and a legitimate one Sarah might make herself. After all, it has her valid session cookie attached to it.

The attack doesn't even need to use a form. It could be as simple as an image tag:

<!-- This also triggers a CSRF attack, though only for GET requests -->
<img src="https://bank.com/transfer?recipient=attacker&amount=1000" 
     style="display: none;">
Enter fullscreen mode Exit fullscreen mode

When the browser tries to load this "image," it sends a GET request to the bank with all of Sarah's cookies attached. If the bank incorrectly accepts GET requests for state-changing operations (a violation of REST principles, but surprisingly common), the attack succeeds.

Why Traditional Defenses Weren't Enough

Before SameSite cookies existed, developers had to implement complex workarounds to prevent CSRF attacks. The most common defense was the CSRF token pattern, and understanding why we needed it helps appreciate the elegance of the SameSite solution.

The CSRF token approach works by including a random, unguessable token with every state-changing request. The server generates this token, ties it to the user's session, and expects it back with form submissions. Since the attacker's site can't read the token (thanks to the Same-Origin Policy), they can't include it in their forged request.

Here's how CSRF tokens work in practice:

// Server-side: Generate and validate CSRF tokens
class CSRFProtection {
    generateToken(sessionId) {
        // Create a random token tied to this session
        const token = crypto.randomBytes(32).toString('hex');

        // Store it server-side, associated with the session
        sessionStorage[sessionId] = {
            csrfToken: token,
            createdAt: Date.now()
        };

        return token;
    }

    validateRequest(request) {
        const sessionId = request.cookies.session;
        const providedToken = request.body.csrfToken || 
                             request.headers['x-csrf-token'];

        // Check if the provided token matches the stored one
        const storedData = sessionStorage[sessionId];

        if (!storedData || !providedToken) {
            throw new Error('CSRF token missing');
        }

        if (storedData.csrfToken !== providedToken) {
            throw new Error('CSRF token mismatch - possible attack!');
        }

        // Token is valid, request is legitimate
        return true;
    }
}

// Client-side: Include CSRF token with requests
async function makeSecureRequest(url, data) {
    // First, get the CSRF token from a meta tag or API endpoint
    const token = document.querySelector('meta[name="csrf-token"]').content;

    const response = await fetch(url, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-Token': token  // Include token in header
        },
        credentials: 'include',  // Include cookies
        body: JSON.stringify({
            ...data,
            csrfToken: token  // Or include in body
        })
    });

    return response;
}
Enter fullscreen mode Exit fullscreen mode

This approach works, but it's cumbersome. Every form needs a hidden input with the token. Every AJAX request needs to include it. Developers need to remember to validate it on every endpoint. It's a lot of moving parts, and if you forget even once, you've created a vulnerability.

The real problem with CSRF tokens is that they're fighting the symptom, not the cause. The root issue is that browsers automatically send cookies with cross-site requests. What if we could just tell the browser not to do that?

The SameSite Guard: Elegant Solution

The SameSite cookie attribute is brilliantly simple in concept. It tells the browser when it's allowed to send a cookie with cross-site requests. Instead of complex token systems, we just configure our cookies to not be sent when the request originates from another site. Let me break down exactly how each SameSite value behaves and when you'd use each one.

SameSite=Strict: The Maximum Security Setting

When you set SameSite=Strict, you're telling the browser to only send this cookie when the request originates from the same site that set it. This completely prevents CSRF attacks because the malicious site literally cannot cause the browser to send your cookie.

But here's where it gets interesting and potentially problematic. "Strict" means STRICT. The browser makes no exceptions, even for seemingly innocent scenarios. Watch what happens in different situations:

SameSite=Strict

The strict behavior creates a frustrating user experience in common scenarios. Imagine you send your users an email with a link to their dashboard. When they click that link, they'll appear logged out, even though they have a valid session cookie, because the navigation originated from their email client, not from your site. They'll have to log in again, and then they'll suddenly be logged in everywhere else on your site. This confusion is why Strict mode, despite being the most secure, isn't always the best choice.

SameSite=Lax

SameSite=Lax represents the sweet spot for most applications, which is why it became the default behavior in modern browsers. It provides strong CSRF protection while maintaining a reasonable user experience. The key innovation of Lax mode is that it distinguishes between different types of cross-site requests.

The Lax mode follows what I call the "Safe Methods for Top-Level Navigation" rule. It allows cookies to be sent with cross-site requests, but only when two specific conditions are met. First, the request must be a top-level navigation, meaning the URL in the browser's address bar changes. Second, it must use a safe HTTP method, specifically GET or HEAD, which by REST conventions shouldn't change server state.

SameSite=Lax

Understanding the Two-Minute Window Exception

There's a fascinating implementation detail in how browsers handle SameSite=Lax that many developers don't know about. For the first two minutes after a cookie is set, some browsers temporarily relax the Lax restrictions for top-level POST navigations. This isn't a bug; it's a deliberate compatibility measure to handle a specific scenario that would otherwise break many payment flows and OAuth redirects.

Consider what happens during a typical payment flow. Your user clicks "Pay Now" on your site, which performs a POST request to the payment processor. The payment processor then needs to POST back to your site with the payment result. Without the two-minute window, your Lax cookies wouldn't be sent with that returning POST request, potentially breaking the user's session right when they're completing their purchase.

// The Two-Minute Window in action
const cookieSetTime = new Date('2024-01-15T10:00:00');
const requestTime = new Date('2024-01-15T10:01:30'); // 90 seconds later

// During the two-minute window after cookie creation:
function isWithinTwoMinuteWindow(setTime, requestTime) {
    const timeDiff = requestTime - setTime;
    const twoMinutes = 2 * 60 * 1000; // in milliseconds
    return timeDiff <= twoMinutes;
}

// Example: OAuth redirect flow
// Step 1: Your site sets a Lax cookie
res.cookie('auth_session', token, { sameSite: 'lax' });

// Step 2: User redirected to OAuth provider
// Step 3: OAuth provider POSTs back to your callback URL
// If this happens within 2 minutes: Cookie IS sent (window exception)
// If this happens after 2 minutes: Cookie NOT sent (standard Lax behavior)

// This is why some OAuth flows mysteriously fail if the user 
// takes too long to authorize on the external site!
Enter fullscreen mode Exit fullscreen mode

SameSite=None: When You Actually Need Cross-Site Cookies

Sometimes you genuinely need cookies to work across different sites. Maybe you're building a widget that other sites embed, or you're implementing single sign-on across multiple domains, or you're dealing with payment processing that involves redirects to third-party services. For these cases, you need SameSite=None, but it comes with important requirements and security implications.

First and foremost, SameSite=None only works with the Secure flag. The browser will actually reject cookies that specify SameSite=None without Secure. This requirement exists because if you're intentionally allowing cross-site requests, you absolutely must encrypt them to prevent interception.

// SameSite=None requirements and use cases

// ❌ This will be rejected by the browser
res.cookie('widget_session', token, {
    sameSite: 'none'  // Missing Secure flag!
    // Browser will ignore this cookie entirely
});

// ✅ Correct way to set SameSite=None
res.cookie('widget_session', token, {
    sameSite: 'none',
    secure: true,  // Required!
    httpOnly: true
});

// Common legitimate use cases for SameSite=None:

// 1. Embedded widgets (like chat widgets, analytics, comments)
// Your widget on customer sites needs to authenticate
if (isEmbeddedWidget) {
    res.cookie('widget_auth', token, {
        sameSite: 'none',  // Allows cross-site embed
        secure: true,
        domain: '.widget-provider.com'
    });
}

// 2. Single Sign-On across different domains
// SSO provider needs to work across company1.com, company2.net, etc.
if (isSSOProvider) {
    res.cookie('sso_session', ssoToken, {
        sameSite: 'none',  // Works across all partner sites
        secure: true,
        httpOnly: true
    });
}

// 3. Payment processor callbacks
// Payment gateway needs to POST back with authentication
if (isPaymentCallback) {
    res.cookie('payment_session', sessionId, {
        sameSite: 'none',  // Allows POST from payment processor
        secure: true,
        maxAge: 3600000  // Short-lived for security
    });
}
Enter fullscreen mode Exit fullscreen mode

SameSite=None

Using SameSite=None essentially brings you back to the pre-SameSite world where CSRF attacks are possible again. This means you need to implement additional CSRF protections like tokens or origin checking when using None. Think of it as consciously opting out of the browser's automatic CSRF protection because your use case requires it.

The "Same Site" vs "Same Origin" Confusion

One of the most confusing aspects of SameSite cookies is understanding what "same site" actually means, especially since web developers are already familiar with the "same origin" concept. These are different things, and the distinction is crucial for understanding when your cookies will or won't be sent.

The Same Origin Policy is strict and looks at the protocol, domain, and port. If any of these differ, it's a different origin. The Same Site concept is more relaxed and only looks at the registrable domain, which is essentially the domain you could purchase from a registrar.

Let me illustrate this critical distinction with concrete examples:

// Understanding Same Site vs Same Origin

// Same Origin comparison (strict)
function isSameOrigin(url1, url2) {
    const u1 = new URL(url1);
    const u2 = new URL(url2);

    return u1.protocol === u2.protocol &&  // https === https
           u1.hostname === u2.hostname &&  // exact match
           u1.port === u2.port;            // same port
}

// Same Site comparison (relaxed)
function isSameSite(url1, url2) {
    // Extract the "registrable domain" (eTLD+1)
    // This is the domain you can actually register
    const getSite = (url) => {
        const u = new URL(url);
        // Simplified - real implementation uses Public Suffix List
        const parts = u.hostname.split('.');
        if (parts.length >= 2) {
            return parts.slice(-2).join('.');  // last two parts
        }
        return u.hostname;
    };

    return getSite(url1) === getSite(url2);
}

// Let's test with real examples:

// Different ORIGINS, but SAME SITE
isSameOrigin('https://app.example.com', 'https://api.example.com');  // false
isSameSite('https://app.example.com', 'https://api.example.com');    // true!

// This is why SameSite=Strict cookies still work across subdomains
// But if you're making fetch() requests between subdomains,
// you still need CORS headers because of Same Origin Policy

// Different protocols: Different ORIGIN, SAME SITE
isSameOrigin('https://example.com', 'http://example.com');  // false
isSameSite('https://example.com', 'http://example.com');    // true

// Different ports: Different ORIGIN, SAME SITE  
isSameOrigin('https://example.com:3000', 'https://example.com:4000');  // false
isSameSite('https://example.com:3000', 'https://example.com:4000');    // true

// Completely different domains: Different everything
isSameOrigin('https://example.com', 'https://google.com');  // false
isSameSite('https://example.com', 'https://google.com');    // false
Enter fullscreen mode Exit fullscreen mode

This distinction explains why you might encounter situations where your cookies are sent (because it's the same site) but your JavaScript fetch() calls fail with CORS errors (because it's a different origin). The browser applies different security policies for different aspects of web security.

The Browser's Cookie Jar: Understanding the Filtering Pipeline

To truly understand how SameSite and other cookie defenses work, we need to understand the sophisticated system that browsers use to manage cookies. The browser maintains what we call a "cookie jar" - essentially a database indexed by domain, path, and name. Think of it as a sophisticated filtering system that runs every single time you make an HTTP request. The browser needs to decide which cookies from potentially hundreds stored locally should be sent with each specific request.

This isn't just a simple lookup table. Every time your browser makes a request—whether it's loading an image, submitting a form, or making a fetch call-it runs through an elaborate filtering pipeline to determine exactly which cookies should be included. Understanding this pipeline is crucial because it explains why your authentication might work in some scenarios but mysteriously fail in others.

Let's visualize what's actually stored in the cookie jar to understand what the browser is working with:

cookies in jar

right click on this image and "open image in a new tab"

filtering cookies

This filtering happens automatically and invisibly every single time your browser makes a request. Understanding this process is crucial because when your authentication fails, it's often because one of these filters rejected your auth cookie. The most common culprits in modern web apps are the SameSite restrictions (especially for single-page applications making cross-origin API calls) and Secure flag issues during local development.

What makes this system particularly interesting is that it's completely transparent to both users and most developers. You never see the rejected cookies or get explicit error messages about why a cookie wasn't sent. The browser simply excludes cookies that fail any check, and your request proceeds without them. This is why debugging cookie issues can feel like fighting ghosts - you're battling against invisible security rules that the browser never explicitly tells you about.

This sophisticated filtering system is also why Server-Side Rendering (SSR) and Server Components can be tricky with authentication. When your server makes a request to another service, it bypasses this entire browser-based security system. The server needs to manually forward the appropriate cookies, essentially recreating the authorization context that the browser would have automatically handled. Without understanding this filtering pipeline, it's easy to accidentally create security vulnerabilities or authentication failures in your SSR implementations.

Best Practices and Recommendations

After working with SameSite cookies across numerous production applications, I've developed a set of best practices that will save you hours of debugging frustration.

For authentication cookies, start with SameSite=Lax as your default. It provides excellent CSRF protection while maintaining good user experience. Only use Strict if you have specific security requirements and can accept the UX tradeoffs. Reserve None for cases where cross-site access is absolutely necessary, and always implement additional CSRF protection when using it.

Consider using different cookies for different purposes. Your main authentication cookie can be Lax for the best balance, while you might have a separate Strict cookie for highly sensitive operations like payment processing or account changes. This gives you granular control over security boundaries.

// Recommended cookie strategy for modern applications

class CookieStrategy {
    // Main authentication cookie - balanced security and UX
    setAuthCookie(res, token) {
        res.cookie('auth', token, {
            httpOnly: true,
            secure: true,
            sameSite: 'lax',  // Good default
            maxAge: 7 * 24 * 60 * 60 * 1000,  // 1 week
            path: '/'
        });
    }

    // High-security operations cookie - maximum protection
    setSecureActionCookie(res, token) {
        res.cookie('secure_action', token, {
            httpOnly: true,
            secure: true,
            sameSite: 'strict',  // Maximum security
            maxAge: 15 * 60 * 1000,  // 15 minutes only
            path: '/account'  // Restricted path
        });
    }

    // Cross-domain SSO cookie - needed for integrations
    setSSOCookie(res, token) {
        res.cookie('sso', token, {
            httpOnly: true,
            secure: true,
            sameSite: 'none',  // Required for cross-site
            maxAge: 60 * 60 * 1000,  // 1 hour
            domain: '.sso-provider.com'
        });

        // Additional CSRF protection since we're using None
        res.cookie('csrf', generateCSRFToken(), {
            secure: true,
            sameSite: 'strict',  // CSRF token can be strict!
            maxAge: 60 * 60 * 1000
        });
    }

    // Remember me cookie - long-lived but limited scope
    setRememberMeCookie(res, token) {
        res.cookie('remember', token, {
            httpOnly: true,
            secure: true,
            sameSite: 'lax',
            maxAge: 30 * 24 * 60 * 60 * 1000,  // 30 days
            path: '/auth'  // Only for auth endpoints
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Always test your cookie behavior across different browsers and scenarios. Set up automated tests that verify cookies work correctly for common user journeys like clicking email links, OAuth flows, and payment processing. Don't assume that because it works in Chrome, it will work the same way in Safari or Firefox.

Remember that SameSite is just one layer of defense. Continue to follow other security best practices like using HTTPS everywhere, implementing proper CORS headers, validating origins for sensitive operations, and keeping your authentication tokens short-lived with refresh token rotation.

Top comments (2)

Collapse
 
fredbrooker_74 profile image
Fred Brooker • Edited

just FYI: that banking hack is not possible, banks don't allow forms being posted from foreign websites, and you have to verify the payment on your mobile app as 2FA

Collapse
 
dmitrevnik profile image
Nikita Dmitriev

that is true
the banking example is a bit of a "classic textbook scenario" that i use because it makes the impact immediately obvious to beginners - everyone understands why unauthorized money transfers are bad
if banking service like that existed it would be vulnerable in such example