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 pyramid.interfaces.ICSRFStoragePolicy. It creates tokens, remembers them somewhere (the session, a cookie or your own store) and verifies a supplied token. Registered with set_csrf_storage_policy.

Token functions

get_csrf_token(request), new_csrf_token(request) and check_csrf_token(request) in pyramid.csrf. They delegate to the registered storage policy.

Origin check

check_csrf_origin(request) compares the Origin (or Referer) header with the trusted origins. It only does anything on HTTPS requests.

Default options

Configurator.set_default_csrf_options(...) registers application-wide defaults: whether views are checked, the name of the form field and header, which methods are safe, whether the origin is checked, and a callback to skip checks.

require_csrf view option

A view argument (add_view, @view_config, @view_defaults) that enables or disables the automatic check for one view, overriding the default.

Exceptions

pyramid.exceptions.BadCSRFToken and pyramid.exceptions.BadCSRFOrigin, both subclasses of HTTPBadRequest. Without an exception view they become a 400 Bad Request response.

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.

flowchart TD A["Request reaches a view with CSRF checking enabled"] --> B{"Is the method in safe_methods?"} B -->|yes| Z["Run the view"] B -->|no| C{"Does callback exist and return False?"} C -->|yes| Z C -->|no| D{"Is check_origin on and the request HTTPS?"} D -->|no| F{"Does the token match the stored token?"} D -->|yes| E{"Is Origin or Referer a trusted origin?"} E -->|no| X1["BadCSRFOrigin: 400 Bad Request"] E -->|yes| F F -->|no| X2["BadCSRFToken: 400 Bad Request"] F -->|yes| Z
Figure 1. The decision made by the CSRF view deriver for each request

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

SessionCSRFStoragePolicy(key='_csrft_')

In the session, under key. Needs a session factory.

The application already has a server-managed or signed-cookie session (the usual case for HTML applications).

CookieCSRFStoragePolicy(...)

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.

LegacySessionCSRFStoragePolicy()

Delegates to request.session.get_csrf_token() and new_csrf_token(). Needs a session factory that implements them.

A session implementation from before 1.9 still manages tokens itself.

Your own ICSRFStoragePolicy

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)

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

get_csrf_token(request)

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.

new_csrf_token(request)

Discards any existing token, creates a new one and returns it. Use it when the privilege level changes.

check_csrf_token(request, token='csrf_token', header='X-CSRF-Token', raises=True)

Looks for the supplied token in the header first, then in request.POST[token]. Returns True for a match. For a mismatch it raises BadCSRFToken (a 400), or returns False when raises=False. Setting header=None or token=None turns that source off.

check_csrf_origin(request, *, trusted_origins=None, allow_no_origin=False, raises=True)

The Origin and Referer validation described in Origin and Referer Checks on HTTPS. Raises BadCSRFOrigin.

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

require_csrf

True

Whether views are checked unless they say otherwise. It is the fallback for the require_csrf view option when that option is None. Note that the function’s default is True, whereas an application that never calls the function checks nothing.

token

'csrf_token'

Name of the field in request.POST. None disables the form-field source.

header

'X-CSRF-Token'

Name of the request header. None disables the header source. If both token and header are None the check is disabled altogether.

safe_methods

('GET', 'HEAD', 'OPTIONS', 'TRACE')

Methods never checked. Everything else, including PUT, PATCH and DELETE, is.

check_origin

True

Whether the Origin and Referer headers are validated on HTTPS requests (added in 2.0).

allow_no_origin

False

Whether an HTTPS request with neither Origin nor Referer passes the origin check (added in 2.0).

callback

None

A callable callback(request); only when it returns True is the request checked. Lets applications skip the check for requests that do not use cookies (added in 1.8).

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)

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.scheme is https. On HTTP it returns True without 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_origins setting (environment variable PYRAMID_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 Origin and Referer fails the check, unless allow_no_origin=True.

  • The opaque origin Origin: null is rejected unless the list contains null.

  • An origin whose scheme is not https is rejected even if its host matches.

  • check_origin=False turns 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

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

cookie_name

'csrf_token'

Name of the cookie. A __Host- prefix (see RFC 6265bis for the browser rules) additionally pins the cookie to the exact host and forbids a Domain attribute.

secure

False

Send the cookie only over HTTPS. Set it to True in production.

httponly

False

Hide the cookie from JavaScript. The default is False because single-page apps often read the cookie to fill the header; if the token is delivered in the HTML or through an endpoint instead, set it to True.

domain

None

Cookie Domain attribute. Leave unset so the cookie is host-only.

max_age

None

Lifetime in seconds; None makes it a session cookie.

path

'/'

Cookie Path.

samesite

'Lax'

SameSite attribute; the option was added in 1.10 with 'Lax' as default.

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:

  1. 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.

  2. The front end sends it in the X-CSRF-Token header 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.

  3. The server enables require_csrf and 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 set_default_csrf_options and never sets require_csrf has no CSRF protection at all, whatever policy it registers. Call set_default_csrf_options(require_csrf=True) from the application’s configuration and opt out view by view.

State change on GET.

Safe methods are never checked. Make every action that writes, deletes or sends something a POST, PUT, PATCH or DELETE.

Token in the URL.

A token in the query string is ignored by check_csrf_token (since 1.7) and would leak through logs and Referer headers if it were accepted. Send it in a header or a form field.

Session cookie flags.

SignedCookieSessionFactory defaults to secure=False and httponly=False; the cookie is signed, not encrypted. Set secure=True and httponly=True in production and keep samesite='Lax' or 'Strict'.

SameSite as the only defence.

SameSite=Lax blocks most cross-site POST requests in current browsers, which is why Pyramid’s cookies default to it, but it is defence in depth: it does not cover same-site subdomain attacks, older browsers, or GET-based actions. Keep the token.

Subdomain and cookie planting.

A sibling subdomain can set cookies for the parent domain. Prefer the session policy, host-only cookies (__Host- prefix) and a narrow trusted-origin list; never trust .example.com unless every subdomain is yours.

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 callback or require_csrf=False.

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 request.scheme is not https. Make the server aware of the original scheme when a proxy terminates TLS.

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() and request.session.new_csrf_token(), and pyramid.session.check_csrf_token. Those module-level functions still exist in pyramid.session but are deprecated aliases; use pyramid.csrf. New session implementations no longer need to provide the CSRF methods.

  • The check_csrf=True view predicate of Pyramid 1.4 to 1.6 was removed in 2.0. Use require_csrf or call check_csrf_token in 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 no allow_no_origin and no way to turn the origin check off short of not using the automatic check.

  • Before 1.10 the cookie policy had no samesite option; it now has one and defaults to 'Lax'.

  • The documentation of require_csrf still 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.