Description
Cookie APIs handle quotes and control characters when serializing ordinary values. Saving a request-derived preference under a fixed, non-session name such as theme is therefore not cookie injection by itself.
Problems can arise when the application trusts the origin of cookie names or session identifiers:
- An untrusted value becomes the cookie name.
- An untrusted value becomes a session identifier.
- An untrusted string becomes the entire
Set-Cookieheader.
Copying external input into a session cookie does not always establish account takeover. Session fixation requires the application to accept an identifier known to the attacker and reuse that identifier or session state after the victim authenticates. Secure, HttpOnly, and SameSite do not fix this lifecycle flaw.
Untrusted cookie names can overwrite a session cookie or another security-sensitive cookie. Giving untrusted input control of the entire header also gives it control of the name, value, scope, and attributes.
Potential impact
- Session fixation and account takeover if an attacker-known identifier remains valid after login.
- Authentication or application-state tampering by overwriting a security-sensitive cookie name.
- Weakened scope or transmission policy through attacker-controlled
Domain,Path,SameSite,Secure, orHttpOnlyattributes.
Review missing security attributes, sensitive cookie contents, and XSS caused by rendering cookie values into HTML separately.
Remediation
- Use the framework's session manager instead of copying request data into a session cookie. The session system must generate server-side identifiers and accept only values it issued.
- Renew the session after login and every privilege change, and invalidate the old identifier. Django's
login()usescycle_key()or replaces the existing session as needed. For Flask or Starlette signed-cookie sessions, clear pre-authentication state before recording the authenticated user. Use the rotation API of any server-side session extension. - Keep cookie names fixed. If name selection is required, map client input to a small set of server-owned constants. Do not trust an allow-list supplied by the client.
- Use
Response.set_cookie()with a trusted name. Do not assign a request-derived string as the entireSet-Cookieheader. Werkzeug'sdump_cookie()structures ordinary cookie values, but neither validates the origin of a session identifier nor rotates it. The server must control names and security attributes. - For session cookies over HTTPS, use
SecureandHttpOnly, chooseSameSiteto suit the flow, minimizeDomainandPath, and prefer__Host-where compatible. These attributes supplement session rotation rather than replace it. - Validate fixed non-session cookie values against business requirements. Sign client-side state whose integrity matters, or store it on the server. A signature protects against tampering, not disclosure.
Generating a value with secrets.token_urlsafe() and putting it in a cookie is not enough. The session lifecycle must also accept only server-issued identifiers, rotate them at authentication, and invalidate old identifiers.
Examples
Session management
Before
FastAPI/Starlette copies a request value into a session cookie:
from fastapi import FastAPI, Request
from starlette.responses import Response
app = FastAPI()
@app.get("/set-session")
async def set_session(request: Request):
response = Response("ok")
response.set_cookie(
key="session",
value=request.query_params["sid"], # Unsafe: a session value known to the attacker
secure=True,
httponly=True,
samesite="lax",
)
return response
Even with all security attributes set, session fixation remains possible if the application reuses the attacker-selected session value after authentication.
After
Use Django's session manager:
from django.contrib.auth import authenticate, login
from django.http import HttpResponse
def sign_in(request):
user = authenticate(
request,
username=request.POST["username"],
password=request.POST["password"],
)
if user is None:
return HttpResponse(status=401)
login(request, user) # Django rotates or safely replaces the session key
return HttpResponse(status=204)
Use Flask's signed-cookie session:
import os
from flask import Flask, abort, request, session
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
@app.post("/login")
def sign_in():
user = verify_credentials(
request.form["username"],
request.form["password"],
)
if user is None:
abort(401)
session.clear() # Remove pre-authentication state
session["user_id"] = user.id
return {"status": "ok"}
verify_credentials represents the application's authentication function. The important change is to avoid copying request data into a session identifier: after successful authentication, clear the old state and record the authenticated user in the framework session.
Cookie names and headers
Before
Flask uses a request value as the cookie name:
from flask import Flask, make_response, request
app = Flask(__name__)
@app.get("/set-cookie")
def set_cookie():
response = make_response("ok")
response.set_cookie(request.args["name"], "1") # Unsafe: a dynamic cookie name
return response
Django sets the entire Set-Cookie header from request input:
from django.http import HttpResponse
def set_raw_cookie(request):
response = HttpResponse("ok")
response.headers["Set-Cookie"] = request.GET["raw"] # Unsafe: the entire raw header
return response
Flask uses request input as a mapping fallback or a serialized session value:
from flask import make_response, request
from werkzeug.http import dump_cookie
def unsafe_cookie_defaults():
names = {"theme": "theme", "locale": "locale"}
response = make_response("ok")
name = names.get("unknown", request.args["name"])
response.set_cookie(name, "light") # Unsafe: the fallback is request data outside the constant mapping
response.headers["Set-Cookie"] = dump_cookie(
"__Host-session", request.args["sid"], secure=True, path="/"
) # Unsafe: serialization does not change the attacker-chosen session ID
return response
After
Use a fixed non-session cookie:
from fastapi import HTTPException, Request
from starlette.responses import Response
async def save_theme(request: Request):
theme = request.query_params.get("theme", "light")
if theme not in {"light", "dark"}:
raise HTTPException(status_code=400)
response = Response("saved")
response.set_cookie(
key="theme",
value=theme,
secure=True,
samesite="lax",
)
return response
The cookie API serializes the value under the fixed theme name. Validation here enforces business rules; it is not a way to sanitize a raw Set-Cookie header.
Select a constant name and serialize an ordinary value:
from flask import make_response, request
from werkzeug.http import dump_cookie
def save_preference():
names = {"theme": "theme", "locale": "locale"}
name = names.get(request.args["kind"], "theme")
response = make_response("ok")
response.set_cookie(name, "light")
response.headers["Set-Cookie"] = dump_cookie("locale", request.args["locale"])
return response
The fallback is also a constant cookie name, so selection remains bounded. locale is a non-session name, and serialization handles cookie syntax. Validate the business meaning of the value separately.