In the previous tutorial, you learned how HTTPS and TLS protect data in transit. But even with HTTPS, your application can be tricked into performing actions on behalf of a user — without the user knowing. This is called CSRF (Cross-Site Request Forgery).
What Is CSRF?
CSRF is an attack where a malicious website tricks your browser into sending a request to another website where you are already logged in. The browser automatically includes your cookies — so the server thinks the request came from you.
Here is a simple example:
- You log in to your bank at
bank.com. The browser stores a session cookie. - You visit a malicious website
evil.com. evil.comhas a hidden form that submits a POST request tobank.com/transfer?to=attacker&amount=1000.- Your browser sends the request with your bank cookies attached.
- The bank processes the transfer because the cookies are valid.
You never clicked “transfer.” The malicious site did it for you.
Why Cookies Are the Root Cause
CSRF works because browsers automatically attach cookies to every request to a domain — regardless of where the request originated. If you are logged in to bank.com, any website can trigger a request to bank.com and your cookies will be included.
This is called ambient authentication. The browser handles authentication automatically, and that is what attackers exploit.
Important: CSRF does not steal your data. The attacker cannot read the response. But they can make your browser perform actions — transfer money, change your email, delete your account.
A Real CSRF Attack
In 2008, a CSRF vulnerability in a popular router (UTStarcom) allowed attackers to change DNS settings by embedding a hidden image tag in a webpage:
<!-- This is the attack — just an image tag -->
<img src="http://192.168.1.1/admin/dns?server=evil-dns.com" />
When a logged-in admin visited any page containing this HTML, the router changed its DNS settings. The attacker did not need the admin’s password.
CSRF Prevention Strategy #1: CSRF Tokens
The most common defense is the synchronizer token pattern. The server generates a random, unpredictable token and includes it in every form. When the form is submitted, the server checks if the token is valid.
The attacker cannot read the token because they cannot access your page’s HTML (same-origin policy prevents that).
CSRF Token Flow
- Server generates a random token and stores it in the user’s session
- Server includes the token as a hidden field in every HTML form
- User submits the form — the token is sent along with the request
- Server verifies the token matches the one stored in the session
- If the token is missing or wrong, the server rejects the request
CSRF Tokens in Go (Gorilla CSRF)
package main
import (
"fmt"
"net/http"
"github.com/gorilla/csrf"
"github.com/gorilla/mux"
)
func main() {
r := mux.NewRouter()
r.HandleFunc("/form", showForm).Methods("GET")
r.HandleFunc("/submit", handleSubmit).Methods("POST")
// 32-byte authentication key for CSRF tokens
csrfMiddleware := csrf.Protect(
[]byte("32-byte-long-auth-key-here123456"),
csrf.Secure(true), // Requires HTTPS
)
fmt.Println("Server running on :8080")
http.ListenAndServe(":8080", csrfMiddleware(r))
}
func showForm(w http.ResponseWriter, r *http.Request) {
// Include the CSRF token in the form
fmt.Fprintf(w, `
<form method="POST" action="/submit">
<input type="hidden" name="gorilla.csrf.Token" value="%s">
<input type="text" name="email">
<button type="submit">Update Email</button>
</form>
`, csrf.Token(r))
}
func handleSubmit(w http.ResponseWriter, r *http.Request) {
// Token is verified automatically by the middleware
fmt.Fprintf(w, "Email updated successfully!")
}
CSRF Tokens in Python (Django)
Django includes CSRF protection by default. You do not need to install anything:
# Django template — the {% csrf_token %} tag inserts the token
# templates/form.html
"""
<form method="POST" action="/submit/">
{% csrf_token %}
<input type="text" name="email">
<button type="submit">Update Email</button>
</form>
"""
# views.py — Django verifies the token automatically
from django.shortcuts import render
from django.http import HttpResponse
def show_form(request):
return render(request, "form.html")
def handle_submit(request):
if request.method == "POST":
email = request.POST.get("email")
# Token was verified by Django middleware automatically
return HttpResponse(f"Email updated to {email}")
Django’s CsrfViewMiddleware is enabled by default in every new project. If you disable it, you lose CSRF protection.
CSRF Tokens in Express.js
import express from "express";
import csrf from "csurf";
import cookieParser from "cookie-parser";
const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
// Set up CSRF protection
const csrfProtection = csrf({ cookie: true });
app.get("/form", csrfProtection, (req, res) => {
// Include the token in the form
res.send(`
<form method="POST" action="/submit">
<input type="hidden" name="_csrf" value="${req.csrfToken()}">
<input type="text" name="email">
<button type="submit">Update Email</button>
</form>
`);
});
app.post("/submit", csrfProtection, (req, res) => {
// Token is verified automatically
res.send("Email updated successfully!");
});
app.listen(3000, () => console.log("Server on :3000"));
Note: The csurf library is deprecated. For new projects, consider using csrf-csrf or the double-submit cookie pattern instead.
CSRF Prevention Strategy #2: SameSite Cookies
Modern browsers support the SameSite cookie attribute, which tells the browser when to include cookies in cross-site requests.
| Value | Behavior | CSRF Protection |
|---|---|---|
Strict | Cookie only sent for same-site requests | Strong — blocks all cross-site cookie sending |
Lax | Cookie sent for top-level GET navigations only | Good — blocks cross-site POST, allows links |
None | Cookie always sent (requires Secure flag) | None — must use other CSRF defenses |
Setting SameSite in Go
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode, // or http.SameSiteStrictMode
Path: "/",
})
Setting SameSite in Python (Django)
# settings.py
SESSION_COOKIE_SAMESITE = "Lax" # "Strict", "Lax", or "None"
SESSION_COOKIE_SECURE = True # Requires HTTPS
SESSION_COOKIE_HTTPONLY = True # Prevents JavaScript access
Setting SameSite in JavaScript (Express)
res.cookie("session_id", sessionToken, {
httpOnly: true,
secure: true,
sameSite: "lax", // "strict", "lax", or "none"
path: "/",
});
Important: SameSite=Lax is the default in modern browsers (Chrome, Firefox, Edge). But do not rely on this alone — older browsers may not support it. Use CSRF tokens as well.
CSRF Prevention Strategy #3: Custom Request Headers
For API requests made with JavaScript (fetch, Axios), you can use a custom header. Browsers enforce the same-origin policy on custom headers — a cross-origin request cannot set custom headers without a CORS preflight.
// Frontend: add a custom header to every API request
fetch("/api/update-email", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest", // Custom header
},
body: JSON.stringify({ email: "alex@example.com" }),
});
// Backend: verify the custom header exists
func csrfHeaderMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" && r.Method != "HEAD" {
if r.Header.Get("X-Requested-With") == "" {
http.Error(w, "Missing CSRF header", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
This works because a malicious site cannot add custom headers to a cross-origin request without triggering a CORS preflight — which the server can reject.
CSRF Prevention Strategy #4: Double-Submit Cookie
This pattern does not require server-side session storage. The server sets a random value in a cookie AND expects the same value in the request body or header.
- Server sets a cookie:
csrf_token=abc123 - Frontend reads the cookie and includes the value in a header:
X-CSRF-Token: abc123 - Server compares the cookie value with the header value
- They match — the request is legitimate
An attacker can cause the browser to send the cookie, but they cannot read it (same-origin policy). So they cannot include the value in the header.
# Python (Flask) double-submit cookie example
import secrets
from flask import Flask, request, make_response, jsonify
app = Flask(__name__)
@app.route("/api/csrf-token", methods=["GET"])
def get_csrf_token():
token = secrets.token_hex(32)
response = make_response(jsonify({"token": token}))
response.set_cookie(
"csrf_token", token,
httponly=False, # Frontend must read this cookie
secure=True,
samesite="Lax"
)
return response
@app.route("/api/update-email", methods=["POST"])
def update_email():
cookie_token = request.cookies.get("csrf_token")
header_token = request.headers.get("X-CSRF-Token")
if not cookie_token or cookie_token != header_token:
return jsonify({"error": "CSRF validation failed"}), 403
# Process the request
return jsonify({"message": "Email updated"})
JWT APIs: CSRF Is Not a Problem
If your API uses JWT tokens in the Authorization header instead of cookies, CSRF is not a concern. The browser does not automatically include the Authorization header — your JavaScript code must add it explicitly.
// JWT in Authorization header — no CSRF risk
fetch("/api/update-email", {
method: "POST",
headers: {
"Authorization": "Bearer eyJhbGciOiJSUzI1NiIs...",
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "alex@example.com" }),
});
The attacker’s website cannot add the Authorization header to a cross-origin request. So CSRF does not apply.
Warning: If you store your JWT in a cookie (some frameworks do this), CSRF applies again. Use SameSite and CSRF tokens in that case.
Which Strategy Should You Use?
| Situation | Recommended Strategy |
|---|---|
| Server-rendered HTML forms | CSRF tokens + SameSite cookies |
| SPA with cookie-based auth | Double-submit cookie + SameSite |
| SPA with JWT in headers | No CSRF protection needed |
| API with cookie-based auth | Custom header + SameSite |
| Mobile app with JWT | No CSRF protection needed |
The safest approach is to combine SameSite cookies + CSRF tokens. Defense in depth.
Testing for CSRF
- Manual test: Open your app, intercept a POST request with browser DevTools, copy it, and replay it from a different origin. Does it succeed?
- Remove the CSRF token from a form submission. Does the server reject it?
- Use OWASP ZAP — it has built-in CSRF testing that scans forms automatically
- Check SameSite cookies — look at your cookies in DevTools and verify
SameSiteis set
Common Mistakes
- Disabling CSRF protection for convenience — Django’s
@csrf_exemptshould be rare, not common - Using GET for state-changing actions — GET requests are not protected by SameSite=Lax
- Not validating the token server-side — including a token in the form means nothing if you do not check it
- SameSite=None without a reason — this disables the browser’s built-in CSRF protection
Prevention Checklist
| Defense | Priority | Notes |
|---|---|---|
| SameSite=Lax on session cookies | High | Default in modern browsers, but set it explicitly |
| CSRF tokens for HTML forms | High | Use framework built-in support (Django, Spring, Gorilla) |
| Custom headers for API requests | Medium | X-Requested-With or X-CSRF-Token |
| Avoid GET for state changes | High | GET requests should only read data |
| Double-submit cookie for SPAs | Medium | No server-side session needed |
| JWT in Authorization header | High | Eliminates CSRF entirely for API calls |
What is Next?
In the next tutorial, you will learn about CORS (Cross-Origin Resource Sharing) — a closely related concept that controls which websites can make requests to your API. CSRF and CORS are often confused, and understanding both is essential for web security.