CSRF Protection
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Pyramid 2.1 (the 2.1-branch documentation and the 2.1 release installed from PyPI) on
Python 3.13, checked on 2026-10-07, with WebTest 3.0.7. Every example
that does not say otherwise was executed with webtest.TestApp. The running example is the Bookshelf Shelves
scenario: public shelves owned by users. A signed-in user can create and delete shelves from an HTML page, and a
single-page front end can do the same through a JSON API. Both rely on a session cookie, and that is exactly the
situation in which cross-site request forgery matters.
Cross-site request forgery (CSRF, sometimes XSRF) is an attack in which a page on another origin makes the victim’s
browser send a request to your application. The browser attaches the victim’s cookies automatically, so a request
such as "delete shelf 7" arrives looking like a legitimate action of the signed-in user. The defence is to require, on
every request that changes state, a value that the attacker’s page cannot know or cannot send: a secret token
bound to the user’s session and delivered in the HTML or JSON the application itself serves. Pyramid ships the
building blocks for that, in the pyramid.csrf module, and a view deriver that applies them automatically. This page
explains each piece, how they combine, and where they stop protecting you. The general theory is in the
OWASP CSRF
Prevention Cheat Sheet, which this site does not duplicate.
The other frameworks of this site solve the same problem with different mechanisms: Flask with Flask-WTF’s
CSRFProtect (Flask: Security Considerations), Tornado with its
xsrf_cookies setting
(Tornado: XSRF and Cross-Origin Protection), Django with
CsrfViewMiddleware (Django: Security) and FastAPI, which has no built-in CSRF layer
and leaves it to the application (FastAPI: Security Fundamentals).
Pyramid sits close to Django in spirit, but the storage of the token is pluggable and the automatic check is a view
deriver rather than a middleware, so it is configured per application and overridden per view.
The Pieces and How They Fit
Pyramid’s CSRF support has six parts. Knowing which part does what makes the options below easy to remember.
| Part | Role |
|---|---|
CSRF storage policy |
An object implementing |
Token functions |
|
Origin check |
|
Default options |
|
|
A view argument ( |
Exceptions |
|
When automatic checking is enabled for a view, the CSRF view deriver wraps the view (it is the csrf_view entry in the
view deriver chain, see
Views and View Configuration) and runs the decision shown below
before the view body. The deriver is created when the view is registered, so the options it uses are those in force
at configuration commit time, not at request time.
Two details of the figure are easy to miss. The token is looked up first in the configured header and then, only if the
header is empty, in request.POST (the form body); it is never read from the query string, a rule added in Pyramid 1.7.
And the whole check is skipped for exception views (views registered with add_exception_view,
add_notfound_view or add_forbidden_view), because the deriver is disabled when a view is exception-only. That is
what keeps the 400 page itself from failing its own check.
Choosing a Storage Policy
A storage policy answers one question: where does the "expected" token live? Pyramid 1.9 introduced the policy interface and moved CSRF out of the session, so the answer is now a configuration choice.
| Policy | Where the token lives | Use it when |
|---|---|---|
|
In the session, under |
The application already has a server-managed or signed-cookie session (the usual case for HTML applications). |
|
In an unauthenticated cookie that the browser sends back; the check compares the cookie with the submitted token (the double submit cookie pattern). No session needed. |
There is no session, or tokens must be verifiable at a front-end server or across many nodes without shared state. |
|
Delegates to |
A session implementation from before 1.9 still manages tokens itself. |
Your own |
Anywhere: a server-side store, a signed token bound to a user id, a hardware-backed service. |
None of the above fits; see A Custom Storage Policy. |
The official security chapter says the session policy is what Pyramid comes with. The code of 2.1 differs slightly: a
Configurator registers LegacySessionCSRFStoragePolicy by default (in add_default_security), which behaves like
the session policy for the built-in sessions because they store the token under the same _csrft_ key. Calling
set_csrf_storage_policy explicitly removes the doubt and costs one line, so applications should do it, as the
example below does. The example also shows the default and the failure mode of an application with no session factory.
import os
import secrets
from pyramid.config import Configurator
from pyramid.csrf import SessionCSRFStoragePolicy, get_csrf_token
from pyramid.interfaces import ICSRFStoragePolicy
from pyramid.response import Response
from pyramid.session import SignedCookieSessionFactory
from webtest import TestApp
# Read the secret from the environment in production; the fallback is a fresh
# random value, which logs everybody out whenever the process restarts.
SESSION_SECRET = os.environ.get("SHELVES_SESSION_SECRET") or secrets.token_hex(32)
# Set SHELVES_HTTPS=1 when the site is served over HTTPS.
COOKIES_SECURE = os.environ.get("SHELVES_HTTPS") == "1"
SHELVES = {1: "To read", 2: "Favourites"}
def shelves_page(request):
token = get_csrf_token(request)
return Response(
'<form method="post" action="/shelves/1/delete">'
f'<input type="hidden" name="csrf_token" value="{token}">'
"<button>Delete</button></form>"
)
def delete_shelf(request):
SHELVES.pop(int(request.matchdict["id"]), None)
return Response("deleted", content_type="text/plain")
def session_factory():
return SignedCookieSessionFactory(
SESSION_SECRET, httponly=True, secure=COOKIES_SECURE, samesite="Lax"
)
# No explicit policy: what does Pyramid 2.1 register?
with Configurator(session_factory=session_factory()) as config:
default_policy = config.registry.queryUtility(ICSRFStoragePolicy)
print(type(default_policy).__name__)
assert type(default_policy).__name__ == "LegacySessionCSRFStoragePolicy"
# Explicit session policy plus automatic checks for every unsafe request
with Configurator(session_factory=session_factory()) as config:
config.set_csrf_storage_policy(SessionCSRFStoragePolicy())
config.set_default_csrf_options(require_csrf=True)
config.add_route("shelves", "/shelves")
config.add_route("shelf_delete", "/shelves/{id}/delete")
config.add_view(shelves_page, route_name="shelves", request_method="GET")
config.add_view(delete_shelf, route_name="shelf_delete", request_method="POST")
app = config.make_wsgi_app()
browser = TestApp(app)
Each policy needs something it can persist. Without a session factory the session policies fail when the token is first
needed (request.session raises an AttributeError), and the cookie policy needs nothing but a browser that returns
cookies.
# A session-based policy without a session factory fails on first use
with Configurator() as config:
config.set_csrf_storage_policy(SessionCSRFStoragePolicy())
config.add_route("shelves", "/shelves")
config.add_view(shelves_page, route_name="shelves")
broken = TestApp(config.make_wsgi_app())
try:
broken.get("/shelves")
except AttributeError as exc:
print("AttributeError:", exc)
Source: API: pyramid.csrf
Issuing, Rotating and Checking Tokens
The three functions of pyramid.csrf take the request and return plain strings or booleans, so they work in views,
subscribers, tweens and tests alike.
| Function | Behaviour |
|---|---|
|
Returns the current token. If none exists yet for this user, creates one (and stores it through the policy) and returns it. Safe to call on every render. |
|
Discards any existing token, creates a new one and returns it. Use it when the privilege level changes. |
|
Looks for the supplied token in the header first, then in |
|
The Origin and Referer validation described in Origin and Referer Checks on HTTPS. Raises |
Tokens reach the browser in one of two ways. In a template, get_csrf_token() is available as a global of every
renderer-driven template (the template page of this section shows it in Jinja2, see
Templates), and its result goes in a hidden input or a header. In a view,
call get_csrf_token(request) and put the result in the context or in a JSON body. A token can only be rotated from
view code, not from a template.
The example below shows the manual workflow: a view that decides for itself, using raises=False, and a login view
that rotates the token and starts a fresh session. The rotation matters because a token that existed before login
could have been planted or observed by someone else, and a CSRF token should not survive a change of privilege.
from pyramid.csrf import check_csrf_token, new_csrf_token
# A generated stand-in credential; real applications verify a password hash
DEMO_USER, DEMO_PASS = "ana", secrets.token_hex(8)
def rename_shelf(request):
if not check_csrf_token(request, raises=False):
return Response("token rejected, ask the user to reload", status=403,
content_type="text/plain")
return Response("renamed", content_type="text/plain")
def login(request):
check_csrf_token(request) # login CSRF: the login form needs a token too
ok = (
request.POST.get("user") == DEMO_USER
and secrets.compare_digest(request.POST.get("pass", ""), DEMO_PASS)
)
if not ok:
return Response("bad credentials", status=401, content_type="text/plain")
request.session.invalidate() # drop every pre-login session value ...
request.session["user"] = DEMO_USER
token = new_csrf_token(request) # ... and issue a token for the new session
return Response(token, content_type="text/plain")
with Configurator(session_factory=session_factory()) as config:
config.set_csrf_storage_policy(SessionCSRFStoragePolicy())
config.add_route("shelves", "/shelves")
config.add_route("rename", "/shelves/{id}/rename")
config.add_route("login", "/login")
config.add_view(shelves_page, route_name="shelves", request_method="GET")
config.add_view(rename_shelf, route_name="rename", request_method="POST")
config.add_view(login, route_name="login", request_method="POST")
manual = TestApp(config.make_wsgi_app())
page = manual.get("/shelves")
pre_login = page.html.find("input", {"name": "csrf_token"})["value"]
print(manual.post("/shelves/1/rename", status=403).text)
print(manual.post("/shelves/1/rename", {"csrf_token": pre_login}).text)
login_body = {"user": DEMO_USER, "pass": DEMO_PASS, "csrf_token": pre_login}
post_login = manual.post("/login", login_body).text
assert post_login != pre_login # the token was rotated
manual.post("/shelves/1/rename", {"csrf_token": post_login}, status=200)
manual.post("/shelves/1/rename", {"csrf_token": pre_login}, status=403)
check_csrf_token compares with strings_differ, a constant-time comparison, so it does not leak the token through
timing. It reads the form field with request.POST, which Pyramid (through WebOb) fills only for form-encoded and
multipart bodies. A JSON body such as {"csrf_token": "..."} is not looked at, so JSON clients must use the header;
this is demonstrated in JSON APIs and Single-Page Applications.
Automatic Checking with set_default_csrf_options
Calling check_csrf_token by hand in every view is exactly the kind of work that gets forgotten on the one view that
matters. Since Pyramid 1.7 the check can be switched on for all views at once.
config.set_default_csrf_options(require_csrf=True)
All parameters of set_default_csrf_options are keyword-friendly and optional:
| Parameter | Default | Meaning |
|---|---|---|
|
|
Whether views are checked unless they say otherwise. It is the fallback for the |
|
|
Name of the field in |
|
|
Name of the request header. |
|
|
Methods never checked. Everything else, including |
|
|
Whether the Origin and Referer headers are validated on HTTPS requests (added in 2.0). |
|
|
Whether an HTTPS request with neither |
|
|
A callable |
Individual views override the default with the require_csrf option, which @view_config, @view_defaults and
add_view all accept: True forces the check, False removes it, and None (the default) follows
set_default_csrf_options. Pyramid refuses require_csrf on exception views.
The example below enables the check globally, exempts a webhook that is called by another server rather than a browser (it authenticates with a shared secret in a header, so cookies do not matter), and checks a single view in an application that has no global default. It also verifies the three transport rules: the token is accepted in a form field and in the header, and ignored in the query string.
def import_webhook(request):
return Response("imported", content_type="text/plain")
with Configurator(session_factory=session_factory()) as config:
config.set_csrf_storage_policy(SessionCSRFStoragePolicy())
config.set_default_csrf_options(require_csrf=True)
config.add_route("shelves", "/shelves")
config.add_route("shelf_delete", "/shelves/{id}/delete")
config.add_route("webhook", "/webhooks/import")
config.add_view(shelves_page, route_name="shelves", request_method="GET")
config.add_view(delete_shelf, route_name="shelf_delete", request_method="POST")
config.add_view(import_webhook, route_name="webhook", request_method="POST",
require_csrf=False)
app = config.make_wsgi_app()
browser = TestApp(app)
browser.post("/webhooks/import", status=200) # exempt
browser.post("/shelves/1/delete", status=400) # no token
token = browser.get("/shelves").html.find("input", {"name": "csrf_token"})["value"]
browser.post(f"/shelves/1/delete?csrf_token={token}", status=400) # query string ignored
browser.post("/shelves/1/delete", {"csrf_token": token}, status=200) # form field
browser.post("/shelves/2/delete", headers={"X-CSRF-Token": token}, status=200) # header
browser.get("/shelves", status=200) # safe method, never checked
# The opposite arrangement: no global default, one view opts in
with Configurator(session_factory=session_factory()) as config:
config.set_csrf_storage_policy(SessionCSRFStoragePolicy())
config.add_route("shelves", "/shelves")
config.add_route("shelf_delete", "/shelves/{id}/delete")
config.add_route("shelf_note", "/shelves/{id}/note")
config.add_view(shelves_page, route_name="shelves", request_method="GET")
config.add_view(delete_shelf, route_name="shelf_delete", request_method="POST",
require_csrf=True)
config.add_view(import_webhook, route_name="shelf_note", request_method="POST")
opt_in = TestApp(config.make_wsgi_app())
opt_in.post("/shelves/1/delete", status=400) # checked
opt_in.post("/shelves/1/note", status=200) # nobody asked for a check
The same option works on class-based views through @view_defaults(require_csrf=True) and is inherited by every
@view_config of the class unless a method overrides it. Because the safe-method list is the only thing that decides
whether a request is checked, a GET view that changes state is never protected: keep every state change on POST,
PUT, PATCH or DELETE.
class ShelfActions:
def __init__(self, request):
self.request = request
def delete(self):
return Response("deleted", content_type="text/plain")
def dry_run(self):
return Response("nothing changed", content_type="text/plain")
with Configurator(session_factory=session_factory()) as config:
config.set_csrf_storage_policy(SessionCSRFStoragePolicy())
config.add_route("shelf_delete", "/shelves/{id}/delete")
# The imperative equivalent of @view_defaults(route_name=..., require_csrf=True)
# with an overriding @view_config on the second method
config.add_view(ShelfActions, attr="delete", route_name="shelf_delete",
request_method="POST", require_csrf=True)
config.add_view(ShelfActions, attr="dry_run", route_name="shelf_delete",
request_method="POST", request_param="dry_run", require_csrf=False)
classes = TestApp(config.make_wsgi_app())
classes.post("/shelves/1/delete", status=400)
classes.post("/shelves/1/delete", {"dry_run": "1"}, status=200)
Source: Pyramid: View Configuration
Origin and Referer Checks on HTTPS
A token check protects against a forged request from another site. It does not protect against a network attacker
who can inject a request into an HTTP connection to the same host and read or set cookies there. For that case
Pyramid adds a second check on HTTPS requests: the Origin header, or if absent the Referer, must name one of the
trusted origins.
The rules, taken from the documentation and checked against the source of check_csrf_origin:
-
The check runs only when
request.schemeishttps. On HTTP it returnsTruewithout looking at anything. This makes the application’s idea of its scheme part of its security: behind a TLS-terminating proxy the WSGI server must be told about the original scheme (for Waitress, through its trusted-proxy settings) or the origin check is silently skipped. The deployment page of this section, covers that wiring. -
By default the only trusted origin is the request’s own host, including its port when it is not 80 or 443.
-
Extra trusted origins go in the
pyramid.csrf_trusted_originssetting (environment variablePYRAMID_CSRF_TRUSTED_ORIGINS), a whitespace- or newline-separated list of host names with ports where they are non-standard. An entry that starts with a dot trusts the domain and all its subdomains. -
A missing
OriginandRefererfails the check, unlessallow_no_origin=True. -
The opaque origin
Origin: nullis rejected unless the list containsnull. -
An origin whose scheme is not
httpsis rejected even if its host matches. -
check_origin=Falseturns the whole check off. Do that only when the storage policy is known to be secure enough that a token cannot easily be used by an attacker, for example a policy whose tokens are bound to the user and cannot be planted from a sibling site.
The example uses the cookie storage policy (see the next section) and an HTTPS URL, so the Origin check is active.
from pyramid.csrf import CookieCSRFStoragePolicy
def token_view(request):
return Response(get_csrf_token(request), content_type="text/plain")
def build_https_app(**csrf_options):
settings = {"pyramid.csrf_trusted_origins": "admin.example.com"}
with Configurator(settings=settings) as config:
config.set_csrf_storage_policy(CookieCSRFStoragePolicy(secure=True))
config.set_default_csrf_options(require_csrf=True, **csrf_options)
config.add_route("token", "/token")
config.add_route("shelf_delete", "/shelves/{id}/delete")
config.add_view(token_view, route_name="token", request_method="GET")
config.add_view(delete_shelf, route_name="shelf_delete", request_method="POST")
return TestApp(config.make_wsgi_app())
site = "https://shelves.example.test"
secure_app = build_https_app()
token = secure_app.get(f"{site}/token").text
delete = f"{site}/shelves/1/delete"
header = {"X-CSRF-Token": token}
secure_app.post(delete, headers=header, status=400) # token ok, but no Origin or Referer
secure_app.post(delete, headers={**header, "Origin": "https://evil.example"}, status=400)
secure_app.post(delete, headers={**header, "Origin": "https://shelves.example.test"}, status=200)
secure_app.post(delete, headers={**header, "Origin": "https://admin.example.com"}, status=200)
secure_app.post(delete, headers={**header, "Referer": f"{site}/shelves"}, status=200)
secure_app.post(delete, headers={**header, "Origin": "null"}, status=400)
lenient = build_https_app(allow_no_origin=True)
lenient_token = lenient.get(f"{site}/token").text
lenient.post(delete, headers={"X-CSRF-Token": lenient_token}, status=200) # no Origin accepted
lenient.post(delete, headers={"X-CSRF-Token": lenient_token,
"Origin": "https://evil.example"}, status=400) # a wrong one never is
The Cookie Storage Policy (Double Submit)
CookieCSRFStoragePolicy stores no server-side state. On first use it generates a random token (a UUID4 in hex form),
sends it to the browser as a cookie, and later compares the cookie with the value the page submits. An attacker’s
page can make the browser send the cookie but cannot read it (same-origin policy), so it cannot copy it into the
form field or header.
| Parameter | Default | Meaning |
|---|---|---|
|
|
Name of the cookie. A |
|
|
Send the cookie only over HTTPS. Set it to |
|
|
Hide the cookie from JavaScript. The default is |
|
|
Cookie |
|
|
Lifetime in seconds; |
|
|
Cookie |
|
|
|
The weakness of any double-submit scheme is that the cookie is not tied to the user: the server only checks that two
values are equal, not that it issued them. Anyone able to set a cookie for your host (a sibling subdomain, or a
network attacker on HTTP) can set both halves. The mitigations are the Origin check above, secure=True, a
__Host- cookie name, and not sharing the parent domain with untrusted sites. Pyramid’s documentation presents the cookie policy as the more flexible option (no session per user, tokens
verifiable by a front-end server) and the session policy as the one that comes with the framework.
with Configurator() as config:
config.set_csrf_storage_policy(
CookieCSRFStoragePolicy(cookie_name="shelves_csrf", httponly=True, samesite="Strict")
)
config.set_default_csrf_options(require_csrf=True)
config.add_route("token", "/token")
config.add_route("shelf_delete", "/shelves/{id}/delete")
config.add_view(token_view, route_name="token", request_method="GET")
config.add_view(delete_shelf, route_name="shelf_delete", request_method="POST")
cookie_app = TestApp(config.make_wsgi_app())
response = cookie_app.get("/token")
print(response.headers["Set-Cookie"])
assert "SameSite=Strict" in response.headers["Set-Cookie"]
assert "HttpOnly" in response.headers["Set-Cookie"]
cookie_app.post("/shelves/1/delete", headers={"X-CSRF-Token": response.text}, status=200)
# a forged request carries the cookie but not the matching value
cookie_app.post("/shelves/1/delete", headers={"X-CSRF-Token": "x" * 32}, status=400)
cookie_app.reset() # the browser drops its cookies
cookie_app.post("/shelves/1/delete", headers={"X-CSRF-Token": response.text}, status=400)
JSON APIs and Single-Page Applications
A common belief is that a JSON API is immune to CSRF because browsers cannot send application/json from a plain
form. That is only partly true. A form can send text/plain bodies that a lenient server parses as JSON, and
misconfigured CORS can allow credentialed cross-origin requests with any content type. The robust position is simple:
if the API authenticates with a cookie, it needs CSRF protection, whatever the body format. If it authenticates with
a header the browser does not attach by itself (Authorization: Bearer ... set by JavaScript), there is nothing for
an attacker’s page to ride on and no CSRF check is needed for those requests.
The shape for a cookie-authenticated SPA is:
-
The page (or an endpoint such as
/api/csrf-token) delivers the current token to the front end, typically once at start-up and again after login. -
The front end sends it in the
X-CSRF-Tokenheader of every unsafe request. The header cannot come from a form, and a cross-origin request that carries a custom header triggers a CORS preflight, which the attacker’s origin fails. -
The server enables
require_csrfand returns a readable error on failure.
Because check_csrf_token does not read JSON bodies, the header is the only transport that works for JSON requests.
// Front end of Bookshelf Shelves (browser)
let csrfToken = null;
async function loadToken() {
const response = await fetch("/api/csrf-token", { credentials: "same-origin" });
csrfToken = (await response.json()).csrfToken;
}
async function createShelf(name) {
const response = await fetch("/api/shelves", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
body: JSON.stringify({ name }),
});
if (response.status === 400) {
await loadToken(); // token expired or rotated: fetch a fresh one and retry once
}
return response;
}
The server side, with a JSON exception view for the two CSRF exceptions so that API clients get JSON instead of an
HTML error page, and with callback skipping the check for requests that authenticate with a bearer token:
from pyramid.exceptions import BadCSRFOrigin, BadCSRFToken
def csrf_token_json(request):
request.response.cache_control = "no-store"
return {"csrfToken": get_csrf_token(request)}
def create_shelf(request):
return {"created": request.json_body["name"]}
def csrf_failed(exc, request):
request.response.status = 400
return {"error": "csrf_failed", "detail": type(exc).__name__}
def cookie_authenticated_only(request):
# Skip the check only when the request is authenticated by something a browser
# does not send on its own. The view must then ignore cookies for such requests.
return not request.headers.get("Authorization", "").startswith("Bearer ")
with Configurator(session_factory=session_factory()) as config:
config.set_csrf_storage_policy(SessionCSRFStoragePolicy())
config.set_default_csrf_options(require_csrf=True, callback=cookie_authenticated_only)
config.add_route("csrf_token", "/api/csrf-token")
config.add_route("shelves_api", "/api/shelves")
config.add_view(csrf_token_json, route_name="csrf_token", request_method="GET",
renderer="json")
config.add_view(create_shelf, route_name="shelves_api", request_method="POST",
renderer="json")
config.add_exception_view(csrf_failed, context=BadCSRFToken, renderer="json")
config.add_exception_view(csrf_failed, context=BadCSRFOrigin, renderer="json")
api = TestApp(config.make_wsgi_app())
token = api.get("/api/csrf-token").json["csrfToken"]
body = {"name": "Poetry"}
failed = api.post_json("/api/shelves", body, status=400)
assert failed.json == {"error": "csrf_failed", "detail": "BadCSRFToken"}
api.post_json("/api/shelves", {**body, "csrf_token": token}, status=400) # JSON body is not read
ok = api.post_json("/api/shelves", body, headers={"X-CSRF-Token": token}, status=200)
assert ok.json == {"created": "Poetry"}
api.post_json("/api/shelves", body, headers={"Authorization": "Bearer demo"}, status=200)
The last line deserves a warning label: the callback only decides whether the CSRF check runs. It does not
authenticate anything. If the security policy of the application also accepts the session cookie for that request
and ignores the bearer token (or merely does not validate it), an attacker’s page can add nothing to a cross-site
request, but a script that already holds a cookie-authenticated browser can claim to be a bearer client. The safe
arrangement is a security policy that, for requests with a bearer token, authenticates only by the token (see the
security policy page of this section).
For the reverse direction, a cross-origin SPA served from another origin, CSRF protection and CORS interact. A
credentialed cross-origin request needs Access-Control-Allow-Credentials: true and an exact (not wildcard) allowed
origin; the browser sends the preflight for the X-CSRF-Token header, and the server must list that header in
Access-Control-Allow-Headers. CORS is not a replacement for a token: it is enforced by the browser, protects the
response from being read, and does not stop the request from being sent in the simple-request cases. The mechanism
and the pitfalls are in What is CORS?. Pyramid has no built-in CORS support; the usual solutions
are a small tween or response callback, or an add-on, both discussed in the page about extending Pyramid.
A Custom Storage Policy
When the session and the cookie are both wrong, implement ICSRFStoragePolicy. The interface has three methods. The
following policy keeps tokens in a server-side store (a dictionary here, which stands for Redis or a database
table) keyed by an opaque, random identifier cookie. Unlike plain double submit, the cookie value alone is useless:
the server must also know the token it issued.
from zope.interface import implementer
@implementer(ICSRFStoragePolicy)
class ServerSideCSRFPolicy:
cookie_name = "csrf_sid"
def __init__(self, store):
self.store = store # replace with a Redis-backed mapping in production
def new_csrf_token(self, request):
sid = request.cookies.get(self.cookie_name) or secrets.token_urlsafe(24)
token = secrets.token_hex(16)
self.store[sid] = token
request.cookies[self.cookie_name] = sid
def set_cookie(request, response):
response.set_cookie(self.cookie_name, sid, httponly=True,
secure=COOKIES_SECURE, samesite="Lax")
request.add_response_callback(set_cookie)
return token
def get_csrf_token(self, request):
sid = request.cookies.get(self.cookie_name)
return self.store.get(sid) or self.new_csrf_token(request)
def check_csrf_token(self, request, supplied_token):
expected = self.get_csrf_token(request)
return secrets.compare_digest(expected.encode(), supplied_token.encode())
store = {}
with Configurator() as config:
config.set_csrf_storage_policy(ServerSideCSRFPolicy(store))
config.set_default_csrf_options(require_csrf=True)
config.add_route("token", "/token")
config.add_route("shelf_delete", "/shelves/{id}/delete")
config.add_view(token_view, route_name="token", request_method="GET")
config.add_view(delete_shelf, route_name="shelf_delete", request_method="POST")
custom = TestApp(config.make_wsgi_app())
issued = custom.get("/token").text
assert list(store.values()) == [issued]
custom.post("/shelves/1/delete", headers={"X-CSRF-Token": issued}, status=200)
custom.post("/shelves/1/delete", headers={"X-CSRF-Token": "wrong"}, status=400)
Whatever the implementation, check_csrf_token of a policy receives the supplied token as text and should compare in
constant time, return a boolean (never raise on a mismatch) and, as in get_csrf_token, create a token when none
exists so that the first request of a new visitor does not crash.
Handling Failures
When a check fails, the view body does not run and the client receives 400 Bad Request with the explanation text of
BadCSRFToken or BadCSRFOrigin. Both are subclasses of HTTPBadRequest, so a generic exception view for
HTTPBadRequest also receives them (see
Errors and Exception Views); register one view per class, as in
the API example above, when only CSRF failures need special treatment. User-friendly handling for HTML forms usually
means: log the failure, re-render the form with a fresh token and an "your session expired, please try again"
message, and not echo the submitted data back into a page with the old token.
A rejected token is not always an attack. The usual honest causes are an expired or replaced session (the signed
cookie session has a default timeout of 1200 seconds), a second browser tab that logged in or out and rotated the
token, a client that blocks cookies, and a cached HTML page that contains a stale token. A response that carries a
token must therefore not be cacheable by shared caches (Cache-Control: no-store or private), and the front end
should refresh its token after login, logout and a failed check.
Testing CSRF Protection
Three kinds of tests are worth having: the happy path works with a token, the same request without one fails, and the
exemptions are exactly the ones intended. WebTest keeps cookies between requests, so a test can behave like a
browser. For unit tests of a view that calls the token functions directly, pyramid.testing supplies a request and a
session that implement the token methods.
from pyramid import testing
def token_from(browser, path="/shelves"):
return browser.get(path).html.find("input", {"name": "csrf_token"})["value"]
def test_delete_needs_a_token():
client = TestApp(app) # the application of the automatic-checking example
client.post("/shelves/1/delete", status=400)
client.post("/shelves/1/delete", {"csrf_token": token_from(client)}, status=200)
def test_unit_level_check():
with testing.testConfig() as config:
request = testing.DummyRequest()
token = get_csrf_token(request)
request.POST = {"csrf_token": token}
assert check_csrf_token(request) is True
request.POST = {"csrf_token": "stale"}
assert check_csrf_token(request, raises=False) is False
test_delete_needs_a_token()
test_unit_level_check()
For real-browser tests of SPAs, see the testing page of this section; the browser tests must exercise the token refresh, because that is the part unit tests never reach.
Pitfalls and Hardening
| Pitfall | What to do |
|---|---|
The default is off. |
An application that never calls |
State change on |
Safe methods are never checked. Make every action that writes, deletes or sends something a |
Token in the URL. |
A token in the query string is ignored by |
Session cookie flags. |
|
|
|
Subdomain and cookie planting. |
A sibling subdomain can set cookies for the parent domain. Prefer the session policy, host-only cookies
( |
XSS defeats CSRF. |
Script running on your origin can read the token and send valid requests. Escape output (see Templates), use a Content Security Policy and treat CSRF protection as a second layer. |
Skipping with |
Each exemption is a statement that cookies cannot authenticate that request. Review them like firewall rules, and test that a cookie alone does not authenticate an exempt endpoint. |
Origin check silently disabled. |
The Origin and Referer check does nothing when |
Login CSRF. |
Forms before authentication (login, sign-up) need tokens too; otherwise an attacker can log the victim into the attacker’s account. Rotate the token on login and logout. |
Multiple tabs and long forms. |
A rotated or expired token fails the submit. Rotate only on privilege changes, show a recoverable error, and do not lose the user’s input. |
Where Older Material Is Outdated
Blog posts and answers written for Pyramid 1.x need care on this topic.
-
Before 1.9 CSRF was a feature of the session:
request.session.get_csrf_token()andrequest.session.new_csrf_token(), andpyramid.session.check_csrf_token. Those module-level functions still exist inpyramid.sessionbut are deprecated aliases; usepyramid.csrf. New session implementations no longer need to provide the CSRF methods. -
The
check_csrf=Trueview predicate of Pyramid 1.4 to 1.6 was removed in 2.0. Userequire_csrfor callcheck_csrf_tokenin the view. -
Before 1.7 a token in the query string was accepted; it is not any more.
-
Before 1.8 there was no
callback; before 2.0 there was noallow_no_originand no way to turn the origin check off short of not using the automatic check. -
Before 1.10 the cookie policy had no
samesiteoption; it now has one and defaults to'Lax'. -
The documentation of
require_csrfstill says that the feature "requires a configured session factory". That was true when sessions were the only storage; with the cookie policy or your own policy no session is needed. -
Pyramid 2.0 made sessions JSON-serializable; tokens are text and are unaffected, but anything else you stored in the session must be too.