XSRF & Cross-Origin 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 Tornado 6.5.10 (released 2026-09-15) on Python 3.13, checked on 2026-10-07. Tornado 6.6 is only available as the alpha 6.6a1 on that date, and that alpha does not even contain the new protection yet: the cross_origin_protection and trusted_origins settings are described from the development documentation and the development source of the main branch, so everything about them is upcoming and may still change before the release. Every example that does not say otherwise was executed against 6.5.10, through real servers bound to a loopback port or through AsyncHTTPTestCase. The running scenario is Bookshelf Live: a shelf page with a form, a JSON API used by a script, a live-updates WebSocket and a webhook, all of them reachable by a browser that carries the reader’s cookies.

Cross-site request forgery (XSRF, also written CSRF) works because browsers attach cookies to a request no matter which site caused it. A page on https://evil.example can make the reader’s browser submit a form to the Bookshelf, and the browser will include the Bookshelf session cookie, so the server cannot tell the forged request from a real one. Every defence therefore has to give the server something the attacker’s page cannot supply or the browser will not send from another site. Tornado offers two of them: the long-standing XSRF cookie (a token that must be echoed in the request, the xsrf_cookies setting) and, from the upcoming 6.6, cross-origin protection (the server inspects headers that browsers add themselves, the cross_origin_protection setting). The page also covers the pieces around them that decide whether either defence is enough: WebSocketHandler.check_origin, mixing cookie and token authentication, CORS and security headers. Signed cookies, get_current_user and @authenticated belong to Authentication & Signed Cookies, and the tornado.auth mixins to Third-party Authentication. The Flask equivalents are in Flask: Forms with Flask-WTF (CSRFProtect) and Flask: Security Considerations; FastAPI has no built-in cookie-based CSRF check, see FastAPI: Security Fundamentals; Django’s middleware is described in Django: Security.

The Two Defences at a Glance

Aspect XSRF cookies (xsrf_cookies) Cross-origin protection (cross_origin_protection)

Available since

Long-standing (version 2 tokens since 3.2.2)

Tornado 6.6 (upcoming, defaults to False; expected to be the default in 6.7)

What the server checks

A token that is both in the _xsrf cookie and in the request (form field, header or argument)

The Sec-Fetch-Site header, and Origin against Host when that header is absent

Changes needed in your pages

Yes: every form, every AJAX call

None

Works for non-browser clients

They must fetch and echo the token (or you exempt the endpoint)

Requests without Sec-Fetch-Site and Origin are always allowed

Weak point

Forgetting a form; token not tied to a session; sibling subdomains can plant cookies

A proxy that strips both headers silently turns the protection off

Status

The development guide no longer recommends it for new applications; deprecation warnings are expected from 6.7

Recommended for new applications from 6.6

For any application running on 6.5.x today, xsrf_cookies is still the only built-in defence for ordinary requests, so most of this page is about it; the section on Fetch Metadata on 6.5 shows how to get most of the benefit of the new approach with a few lines of application code.

How the XSRF Cookie Works

The mechanism is the double submit cookie pattern. The first time a handler touches self.xsrf_token, Tornado generates a random token and stores it in the _xsrf cookie. Every state-changing request must carry the same value a second time, in a place only code running on your own site can fill in: a hidden form field named _xsrf, a request argument, or the X-XSRFToken header. An attacker’s page can make the browser send the cookie, but cannot read it (cookies are not readable across origins) and therefore cannot copy it into the request.

sequenceDiagram participant B as Browser participant S as Tornado participant E as Attacker page B->>S: GET / (page with a form) S-->>B: Set-Cookie _xsrf + hidden _xsrf field in the form B->>S: POST /books with Cookie _xsrf and field _xsrf S->>S: check_xsrf_cookie compares both values S-->>B: 200 book added E->>B: forged form targeting the Bookshelf B->>S: POST /books with Cookie _xsrf but no field S-->>B: 403 Forbidden
The browser echoes the XSRF token in the request; a forged request carries the cookie but not the token

Three details of the implementation matter in practice:

  • The check runs in the request machinery before prepare() and the handler method, and only for methods other than GET, HEAD and OPTIONS. So PUT, PATCH and DELETE are checked too, and a GET handler that changes state is unprotected. The built-in ErrorHandler that answers unmatched routes skips the check, so a POST to a wrong URL returns 404 instead of 403.

  • The cookie is only created when something reads self.xsrf_token. xsrf_form_html() does, a plain JSON GET does not. A single-page application that never renders a form must read self.xsrf_token explicitly in some handler (for example the one that serves index.html or a "session" endpoint), otherwise every POST is rejected.

  • The token is not tied to a user or session. It is a random value kept in a cookie, which is also why the protection should be paired with SameSite cookies and why sibling subdomains need attention (see the pitfalls below).

Enabling It and Protecting Forms

Setting xsrf_cookies=True on the Application enables the check. Forms must include the token: in a template, the xsrf_form_html UI module renders the hidden <input>, written {% module xsrf_form_html() %}. The next example defines a small browser stand-in (a cookie jar on top of Tornado’s HTTP client) and the shelf application that the following examples reuse; make_app accepts extra settings so that later examples can vary them.

import asyncio
import hashlib
import hmac
import json
import logging
import re
import secrets
from http.cookies import SimpleCookie
from urllib.parse import urlencode, urlsplit

from tornado import httpclient, httpserver, netutil, template, web
from tornado.escape import json_decode


class Capture(logging.Handler):
    """Collect the warnings Tornado logs for rejected requests."""

    def __init__(self):
        super().__init__()
        self.lines = []

    def emit(self, record):
        if record.name == "tornado.general":
            self.lines.append(record.getMessage().splitlines()[0])


captured = Capture()
tornado_log = logging.getLogger("tornado")
tornado_log.addHandler(captured)
tornado_log.propagate = False
tornado_log.setLevel(logging.INFO)


class Browser:
    """A server on a free loopback port plus a cookie jar, standing in for a browser."""

    def __init__(self, app):
        self.app = app
        self.cookies = {}

    async def __aenter__(self):
        self.server = httpserver.HTTPServer(self.app)
        sockets = netutil.bind_sockets(0, "127.0.0.1")
        self.server.add_sockets(sockets)
        self.base = f"http://127.0.0.1:{sockets[0].getsockname()[1]}"
        return self

    async def __aexit__(self, *exc_info):
        self.server.stop()
        await self.server.close_all_connections()

    async def request(self, method, path, headers=None, body=None, send_cookies=True):
        headers = dict(headers or {})
        if send_cookies and self.cookies:
            headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in self.cookies.items())
        if body is None and method in ("POST", "PUT", "PATCH"):
            body = ""
        response = await httpclient.AsyncHTTPClient().fetch(
            self.base + path, method=method, headers=headers, body=body,
            raise_error=False, follow_redirects=False)
        for line in response.headers.get_list("Set-Cookie"):
            for name, morsel in SimpleCookie(line).items():
                self.cookies[name] = morsel.value
        return response


FORM = {"Content-Type": "application/x-www-form-urlencoded"}
SHELF_PAGE = """<h1>Bookshelf Live</h1>
<form action="/books" method="post">
  {% module xsrf_form_html() %}
  <input name="title"><button>Add</button>
</form>"""
BOOKS = []


class ShelfHandler(web.RequestHandler):
    def get(self):
        self.render("shelf.html")


class PlainHandler(web.RequestHandler):
    def get(self):
        self.write("no form here")


class BooksHandler(web.RequestHandler):
    def post(self):
        BOOKS.append(self.get_body_argument("title"))
        self.write({"books": BOOKS})


def make_app(**extra):
    settings = dict(
        template_loader=template.DictLoader({"shelf.html": SHELF_PAGE}),
        xsrf_cookies=True,
    )
    settings.update(extra)
    return web.Application([
        (r"/", ShelfHandler), (r"/plain", PlainHandler), (r"/books", BooksHandler),
    ], **settings)


async def main():
    async with Browser(make_app()) as browser:
        plain = await browser.request("GET", "/plain")
        print("GET /plain -> cookie set:", "Set-Cookie" in plain.headers)

        page = await browser.request("GET", "/")
        print("GET /      -> cookie set:", "_xsrf" in browser.cookies)
        field = re.search(r'name="_xsrf" value="([^"]+)"', page.body.decode()).group(1)

        forged = await browser.request("POST", "/books", FORM, urlencode({"title": "Forged"}))
        print("POST without token ->", forged.code)

        body = urlencode({"title": "Dune", "_xsrf": field})
        genuine = await browser.request("POST", "/books", FORM, body)
        print("POST with form token ->", genuine.code, json_decode(genuine.body))

        wrong = urlencode({"title": "Forged", "_xsrf": "2|00|00|1"})
        print("POST with a wrong token ->", (await browser.request("POST", "/books", FORM, wrong)).code)

        nocookie = await browser.request("POST", "/books", FORM, body, send_cookies=False)
        print("POST with token but no cookie ->", nocookie.code)
    for line in captured.lines:
        print("log:", line)


asyncio.run(main())

Every rejection is a plain 403 whose reason the client does not see; the explanation (token missing, invalid format or mismatch) is only in the log. That is deliberate, but it makes failures confusing during development, so keep the tornado.general log visible in tests (see Testing).

What the Token Looks Like

self.xsrf_token returns bytes (ASCII only). With the default xsrf_cookie_version, the token is masked: every call produces a different string, built from a random 4-byte mask and the real token XORed with it, so that a token printed into a compressed page cannot be recovered by the BREACH attack. The cookie and the form field of the same session therefore differ as strings but represent the same token, and check_xsrf_cookie unmasks both before a constant-time comparison. The layout (2|mask|masked-token|timestamp) is an implementation detail rather than API, but unmasking it once makes the behaviour obvious.

def unmask(value):
    version, mask, masked, timestamp = value.split("|")
    mask, masked = bytes.fromhex(mask), bytes.fromhex(masked)
    return bytes(b ^ mask[i % 4] for i, b in enumerate(masked)).hex(), version


async def main():
    async with Browser(make_app()) as browser:
        first = await browser.request("GET", "/")
        cookie = browser.cookies["_xsrf"]
        second = await browser.request("GET", "/")      # same cookie sent, new mask in the page
        fields = [re.search(r'value="([^"]+)"', r.body.decode()).group(1) for r in (first, second)]
        print("cookie equals first field:", cookie == fields[0])   # the cookie was set by that first response
        print("cookie differs from second field:", cookie != fields[1])
        print("fields differ from each other:", fields[0] != fields[1])
        print("same underlying token:", unmask(cookie)[0] == unmask(fields[0])[0] == unmask(fields[1])[0])
        print("version:", unmask(cookie)[1])


asyncio.run(main())

The xsrf_cookie_version setting selects the format of newly created cookies. Leave it at its default (the highest version, 2) unless you are migrating clients that still hold version 1 cookies, which are plain hex tokens: Tornado accepts both and converts version 1 cookies to version 2 when xsrf_token is read, unless the setting is pinned to 1.

AJAX, JSON APIs and Single-Page Applications

Forms are only half of the story. A script that sends JSON cannot use xsrf_form_html(), so the token travels in a header: check_xsrf_cookie looks for the value in the _xsrf argument (query string or form body), then in the X-XSRFToken header and finally in X-CSRFToken, the name Django and Angular use, accepted for compatibility. The X-Requested-With: XMLHttpRequest shortcut that some frameworks (and Tornado before 1.1.1) used to trust is not honoured: a custom header only proves anything if the server checks its value.

A JSON body is never searched for _xsrf, so a JSON POST must use the header (or the query string). The example shows the bootstrap pattern: a GET endpoint reads self.xsrf_token, which creates the cookie and also returns the value to a script that is not allowed to read cookies.

class ApiBooksHandler(web.RequestHandler):
    def get(self):
        self.write({"books": BOOKS, "xsrf": self.xsrf_token.decode()})   # reading the property sets the cookie

    def post(self):
        BOOKS.append(json_decode(self.request.body)["title"])
        self.set_status(201)
        self.write({"books": BOOKS})

    def delete(self):
        BOOKS.clear()
        self.set_status(204)


async def main():
    app = web.Application([(r"/api/books", ApiBooksHandler)], xsrf_cookies=True)
    async with Browser(app) as browser:
        token = json_decode((await browser.request("GET", "/api/books")).body)["xsrf"]
        payload = json.dumps({"title": "Hyperion"})
        JSON = {"Content-Type": "application/json"}

        r = await browser.request("POST", "/api/books", JSON, payload)
        print("JSON POST, no token:", r.code)
        r = await browser.request("POST", "/api/books", {**JSON, "X-XSRFToken": token}, payload)
        print("X-XSRFToken header:", r.code)
        r = await browser.request("POST", "/api/books", {**JSON, "X-CSRFToken": token}, payload)
        print("X-CSRFToken header:", r.code)
        r = await browser.request("POST", "/api/books?_xsrf=" + token, JSON, payload)
        print("_xsrf query argument:", r.code)
        r = await browser.request("DELETE", "/api/books")
        print("DELETE, no token:", r.code)
        r = await browser.request("DELETE", "/api/books", {"X-XSRFToken": token})
        print("DELETE with header:", r.code)


asyncio.run(main())

Browser code then reads the token from wherever the server put it and sends it on every unsafe request. Reading the cookie from document.cookie works only while the cookie is not HttpOnly; if you set httponly in xsrf_cookie_kwargs (a good idea, see below), hand the token to the script through a <meta> element rendered by the page or through a bootstrap endpoint like the one above.

// token comes from <meta name="xsrf-token" content="..."> rendered by the server
const token = document.querySelector('meta[name="xsrf-token"]').content;

async function addBook(title) {
  const response = await fetch("/api/books", {
    method: "POST",
    credentials: "same-origin",
    headers: {"Content-Type": "application/json", "X-XSRFToken": token},
    body: JSON.stringify({title}),
  });
  if (response.status === 403) throw new Error("XSRF token rejected: reload the page");
  return response.json();
}

The XSRF Settings

All of them are keyword arguments of Application, listed with the others in the settings table of Applications & Request Handlers.

Setting Effect Default

xsrf_cookies

Enables the check for every request whose method is not GET, HEAD or OPTIONS.

False

xsrf_cookie_name

Name of the cookie. Added in 6.3 so that the __Host- prefix can be used (the prefix needs secure and Path=/ and forbids a Domain attribute). It must be combined with xsrf_cookie_kwargs to satisfy the prefix rules.

_xsrf

xsrf_cookie_kwargs

A dictionary passed to set_cookie for the XSRF cookie: secure, httponly, samesite, domain, path, expires_days. Added in 4.3. When the user is signed in and no expires_days is given, 6.5.10 sets 30 days.

{}

xsrf_cookie_version

Format of newly produced cookies (1 or 2).

2

cross_origin_protection (upcoming, 6.6)

Enables Fetch Metadata and Origin checking, described below.

False

trusted_origins (upcoming, 6.6)

A list of origins that check_origin accepts, such as https://app.example.com.

[]

A hardened configuration for the Bookshelf behind HTTPS therefore combines a prefixed name with restrictive cookie attributes. The next example prints the resulting Set-Cookie header; the request is plain HTTP, but the server emits the attributes regardless of the scheme (a real browser would refuse to store a Secure cookie from an insecure origin, so use this configuration behind TLS only).

async def main():
    app = make_app(
        xsrf_cookie_name="__Host-xsrf",
        xsrf_cookie_kwargs={"secure": True, "httponly": True, "samesite": "Lax"},
    )
    async with Browser(app) as browser:
        page = await browser.request("GET", "/")
        print(page.headers["Set-Cookie"])
        field = re.search(r'value="([^"]+)"', page.body.decode()).group(1)
        body = urlencode({"title": "Foundation", "_xsrf": field})
        print("POST ->", (await browser.request("POST", "/books", FORM, body)).code)


asyncio.run(main())

RequestHandler.check_xsrf_cookie() is a normal method you can override. Raise HTTPError(403, ...) to reject, return to accept. The guide is blunt about the intent: keep the protection for every request authenticated by cookies, and override only where another mechanism proves the request is genuine. A payment or Git hosting webhook is the typical case: it is called by a server, not a browser, so there is no XSRF cookie, but it signs its body with a shared key. Replace the check with a signature check, do not just return.

WEBHOOK_KEY = secrets.token_bytes(32)      # shared with the sender; load it from your secret store


class WebhookHandler(web.RequestHandler):
    def check_xsrf_cookie(self):
        expected = hmac.new(WEBHOOK_KEY, self.request.body, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(self.request.headers.get("X-Signature", ""), expected):
            raise web.HTTPError(403, "bad webhook signature")

    def post(self):
        self.write({"received": json_decode(self.request.body)})


async def main():
    app = web.Application([(r"/hooks/shelf", WebhookHandler)], xsrf_cookies=True)
    async with Browser(app) as browser:
        body = json.dumps({"event": "book.added"})
        signature = hmac.new(WEBHOOK_KEY, body.encode(), hashlib.sha256).hexdigest()
        ok = await browser.request("POST", "/hooks/shelf", {"X-Signature": signature}, body)
        bad = await browser.request("POST", "/hooks/shelf", {"X-Signature": "0" * 64}, body)
        print("signed:", ok.code, "forged signature:", bad.code)


asyncio.run(main())

An override that is a bare pass leaves the endpoint open to forged requests from any site, for as long as the visitor’s browser holds cookies the endpoint honours. Prefer a narrow, visible override on one handler class over a global switch such as xsrf_cookies=False.

Mixed Cookie and Token Authentication

The XSRF check protects requests that the browser authenticates implicitly (cookies, and also HTTP Basic credentials and client certificates). A request authenticated by an Authorization: Bearer header that your own script adds is not forgeable that way, because another site cannot make the browser add that header. Applications that serve both kinds of client are tempted to skip the check whenever an Authorization header is present. That is safe only if the handler then never falls back to the cookie:

  • Skip the XSRF check only when the bearer token is present and valid, and authenticate such a request from the header alone (never read the session cookie for it).

  • Requests without the header keep the full check.

  • Do not decide on the Content-Type (JSON is not a defence: a form can send text/plain that a lenient server parses as JSON) or on the presence of an arbitrary custom header.

API_TOKENS = {secrets.token_hex(16): "alice"}       # issued by your token endpoint in a real application


class ApiHandler(web.RequestHandler):
    def _bearer_user(self):
        scheme, _, token = self.request.headers.get("Authorization", "").partition(" ")
        return API_TOKENS.get(token) if scheme == "Bearer" else None

    def check_xsrf_cookie(self):
        if self._bearer_user() is None:          # no valid bearer token: full check, cookies in play
            super().check_xsrf_cookie()

    def get_current_user(self):
        return self._bearer_user()               # this handler never reads the session cookie

    def post(self):
        if not self.current_user:
            raise web.HTTPError(401)
        self.write({"added_by": self.current_user})


async def main():
    app = web.Application([(r"/api/shelf", ApiHandler)], xsrf_cookies=True)
    async with Browser(app) as browser:
        token = next(iter(API_TOKENS))
        r = await browser.request("POST", "/api/shelf", {"Authorization": f"Bearer {token}"})
        print("valid bearer token, no XSRF token:", r.code, json_decode(r.body))
        r = await browser.request("POST", "/api/shelf", {"Authorization": "Bearer not-a-token"})
        print("invalid bearer token:", r.code)
        r = await browser.request("POST", "/api/shelf")
        print("no credentials:", r.code)


asyncio.run(main())

The remaining pitfalls of the cookie-based defence:

  • Login CSRF. The check also applies to the login form: if the login POST is exempt, an attacker can log the visitor in as the attacker. Render xsrf_form_html() there too.

  • State-changing GET. A GET /books/delete?id=3 is never checked. Use POST or DELETE.

  • Sibling subdomains. A cookie set by evil.example.com for the parent domain can overwrite _xsrf on app.example.com, so an attacker who controls any subdomain can choose both halves of the double submit. The __Host- prefix (cookie accepted only if set by the exact host) and SameSite mitigate this.

  • Cookie attributes. Without xsrf_cookie_kwargs the cookie is sent without Secure, HttpOnly or SameSite. SameSite=Lax already stops browsers from sending the cookie on cross-site POST requests; treat it as an extra layer, not a replacement, because it depends on browser behaviour and does not apply to same-site attackers.

  • XSS defeats everything. A script running on your origin can read the page’s token. Autoescaping, a Content-Security-Policy and careful templates (see Templates & UI Modules) are prerequisites.

  • Caching. A page containing a token must not be cached by a shared cache; Cache-Control: private or no-store on pages with forms.

  • Rotation. The token is not rotated on login by Tornado. If you want a fresh token after authentication, clear the cookie (clear_cookie with the configured name) in the login handler.

Fetch Metadata on Tornado 6.5

Modern browsers label every request with a Sec-Fetch-Site header (same-origin, same-site, cross-site or none for a user-initiated navigation) that page scripts cannot forge. Servers can reject unsafe requests that the browser says came from another site, without any token. Tornado 6.6 builds this in (next section); on 6.5 the same check is a small piece of application code in a base handler. The version below follows the documented 6.6 algorithm, so migrating later means deleting it. It is application code, not a Tornado API.

TRUSTED_ORIGINS = {"https://app.example.com"}


class FetchMetadataMixin:
    """Reject unsafe requests that browsers mark as cross-site. Put it first in the base classes."""

    def prepare(self):
        if self.request.method in ("GET", "HEAD", "OPTIONS"):
            return
        site = self.request.headers.get("Sec-Fetch-Site")
        origin = self.request.headers.get("Origin")
        if site in ("same-origin", "none"):
            return
        if site is None and origin is None:
            return                                  # old browser or non-browser client
        if site is None and urlsplit(origin).netloc.lower() == self.request.host.lower():
            return                                  # no Fetch Metadata: compare Origin and Host
        if origin in TRUSTED_ORIGINS:
            return
        raise web.HTTPError(403, "cross-origin request %s from %s", site, origin)


class GuardedBooksHandler(FetchMetadataMixin, web.RequestHandler):
    def post(self):
        self.write({"added": self.get_body_argument("title")})


async def main():
    app = web.Application([(r"/books", GuardedBooksHandler)])      # no xsrf_cookies at all
    async with Browser(app) as browser:
        host = browser.base.removeprefix("http://")
        cases = {
            "non-browser client": {},
            "same-origin": {"Sec-Fetch-Site": "same-origin"},
            "cross-site": {"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"},
            "cross-site, trusted origin": {"Sec-Fetch-Site": "cross-site", "Origin": "https://app.example.com"},
            "no Sec-Fetch-Site, Origin equals Host": {"Origin": f"http://{host}"},
            "no Sec-Fetch-Site, foreign Origin": {"Origin": "https://evil.example"},
        }
        for name, headers in cases.items():
            r = await browser.request("POST", "/books", {**FORM, **headers}, "title=Emma")
            print(f"{name}: {r.code}")


asyncio.run(main())

Cross-Origin Protection in Tornado 6.6 (Upcoming)

The 6.6 release notes (still dated "Oct XX, 2026" on 2026-10-07) announce cross_origin_protection as "a new mechanism for protecting against XSRF and other cross-origin attacks" that is "easier to use than `xsrf_cookies`" and "expected to become the default in Tornado 6.7". The development guide documents it as follows; none of it is in 6.5.10 or in the 6.6a1 package, so the example below is not executed here.

  • cross_origin_protection=True makes the application reject state-changing requests (anything but GET, HEAD and OPTIONS) when Sec-Fetch-Site is set to something other than same-origin or none. When the header is missing, it compares Origin with Host and rejects a mismatch. Requests with neither header are non-browser clients and are always allowed.

  • trusted_origins is a list of extra origins to accept. An origin is scheme, host and optional port with no trailing slash, never a URL with a path, and wildcards are not supported. An origin is narrower than a site, so an application that relied on same-site traffic from subdomains lists them explicitly or overrides check_origin.

  • check_origin(origin) moves from WebSocketHandler to RequestHandler in 6.6. By default it tests membership in trusted_origins. It is called only for requests that appear cross-origin, never for known same-origin requests or requests without an Origin header, and it is always called for WebSocket handshakes. Overriding it to return True exempts a handler (suitable for an endpoint meant to be called from anywhere, and never one that relies on cookies).

  • The feature defaults to False in 6.6, is expected to default to True in 6.7, and xsrf_cookies starts emitting deprecation warnings in 6.7 and may be removed in 7.0 at the earliest.

  • It is unrelated to CORS: it neither adds nor removes Access-Control-Allow-Origin, so an application using it still has its CORS requirements to consider (see CORS Headers for APIs).

  • Sec-Fetch-Site is sent by all major browsers since 2023, but only over HTTPS or to localhost; plain HTTP deployments rely on the Origin/Host fallback. Reverse proxies must pass Sec-Fetch-Site, Origin and Host through unchanged: a proxy that strips both headers disables the protection silently.

flowchart TD A[Unsafe request arrives, such as POST or DELETE] --> B{Sec-Fetch-Site} B -->|same-origin or none| OK[Allowed] B -->|missing| C{Origin header} C -->|missing: non-browser client| OK C -->|present| D{Origin equals Host or trusted} D -->|yes| OK D -->|no| X[403 Forbidden] B -->|cross-site or same-site| E{Origin in check_origin} E -->|yes| OK E -->|no| X
The decision cross_origin_protection makes for a state-changing request, as documented for 6.6
# norun - needs Tornado 6.6, not released at the time of writing
import urllib.parse

from tornado import web


class BookshelfHandler(web.RequestHandler):
    def check_origin(self, origin):
        # allow any sub-domain of our own site, in addition to the trusted_origins list
        netloc = urllib.parse.urlsplit(origin).netloc
        return super().check_origin(origin) or netloc.endswith(".bookshelf.example")


application = web.Application(
    [(r"/books", BookshelfHandler)],
    cross_origin_protection=True,
    trusted_origins=["https://app.bookshelf.example"],
)

Until the release, plan the migration rather than the code: add Sec-Fetch-Site, Origin and Host pass-through to the proxy configuration, decide the trusted origins, enable the setting in staging, and keep xsrf_cookies on for the transition if clients without these headers must be covered.

WebSocket Handshakes: check_origin

WebSockets bypass the browser’s same-origin policy and do not use CORS, so a page on any site can open a WebSocket to your server and the browser will include the visitor’s cookies. If the connection is then trusted because of the cookie, any site can read the visitor’s live data and send commands as them (cross-site WebSocket hijacking). Tornado guards the handshake with WebSocketHandler.check_origin(origin): when the request carries an Origin header, the default implementation accepts it only if its host (and port) equals the Host header, and answers 403 otherwise. Requests without Origin (non-browser clients) are always accepted. The XSRF cookie check does not apply to the GET handshake, so check_origin is the protection.

Override it to allow a front-end hosted elsewhere, with an exact allow-list; never return True blindly while the authentication is cookie-based, and implement an XSRF-like token (for example one-time ticket in the first message) if you must accept foreign origins. In 6.6 the method moves to RequestHandler and uses trusted_origins by default, with the same semantics for the handshake. The wiring of handlers, messages and heartbeats is covered in WebSockets.

from tornado import websocket

ALLOWED_WS_ORIGINS = {"https://app.example.com"}


class LiveShelfSocket(websocket.WebSocketHandler):
    def check_origin(self, origin):
        return origin in ALLOWED_WS_ORIGINS or super().check_origin(origin)

    def open(self):
        self.write_message("connected")


async def main():
    app = web.Application([(r"/live", LiveShelfSocket)])
    async with Browser(app) as browser:
        url = browser.base.replace("http", "ws") + "/live"
        host_origin = browser.base
        for origin in (None, host_origin, "https://app.example.com", "https://evil.example"):
            headers = {"Origin": origin} if origin else {}
            try:
                connection = await websocket.websocket_connect(httpclient.HTTPRequest(url, headers=headers))
                print(f"Origin {origin}: accepted, first message {await connection.read_message()!r}")
                connection.close()
            except httpclient.HTTPClientError as error:
                print(f"Origin {origin}: refused with {error.code}")


asyncio.run(main())

CORS Headers for APIs

Cross-origin resource sharing is a separate mechanism from XSRF protection. CORS relaxes the browser’s same-origin policy so that a script on https://app.example.com may call an API on another origin and read the response; it does not stop a forged form submission (which needs no response) and it does not authenticate anyone. Tornado has no CORS support: you set the Access-Control-* headers yourself, and a mistake is a security hole. The rules are explained in What is CORS?. For Tornado:

  • Reflect the Origin only after matching it against an allow-list, and always send Vary: Origin so that caches do not serve one origin’s answer to another.

  • Never combine Access-Control-Allow-Origin: * with credentials; the browser refuses it, and reflecting any origin together with Access-Control-Allow-Credentials: true is as bad as no protection.

  • Answer the preflight OPTIONS request yourself (it is not subject to the XSRF check) and list the custom header that carries the token, X-XSRFToken, in Access-Control-Allow-Headers; otherwise the browser blocks the real request.

  • set_default_headers runs for every response, including error responses written by write_error.

CORS_ORIGINS = {"https://app.example.com"}


class CorsMixin:
    def set_default_headers(self):
        self.set_header("Vary", "Origin")
        origin = self.request.headers.get("Origin")
        if origin in CORS_ORIGINS:
            self.set_header("Access-Control-Allow-Origin", origin)
            self.set_header("Access-Control-Allow-Credentials", "true")

    def options(self, *args, **kwargs):               # the preflight
        self.set_header("Access-Control-Allow-Methods", "GET, POST, DELETE")
        self.set_header("Access-Control-Allow-Headers", "Content-Type, X-XSRFToken")
        self.set_header("Access-Control-Max-Age", "600")
        self.set_status(204)


class CorsBooksHandler(CorsMixin, web.RequestHandler):
    def get(self):
        self.write({"books": ["Dune"]})


async def main():
    app = web.Application([(r"/api/books", CorsBooksHandler)], xsrf_cookies=True)
    async with Browser(app) as browser:
        for origin in ("https://app.example.com", "https://evil.example"):
            pre = await browser.request("OPTIONS", "/api/books", {"Origin": origin})
            get = await browser.request("GET", "/api/books", {"Origin": origin})
            print(origin, "->", pre.code, get.headers.get("Access-Control-Allow-Origin"), get.headers.get("Vary"))


asyncio.run(main())

The remaining layer is a handful of response headers that reduce what an attacker who gets past the other defences can do. Set them once in a base handler’s set_default_headers; a reverse proxy can add them instead, but then nothing guards the application when it is reached directly.

Header Purpose

Content-Security-Policy

Limits where scripts, styles and frames may come from; frame-ancestors also stops clickjacking (see the Django: Content Security Policy page for the header itself).

X-Content-Type-Options: nosniff

Stops browsers from guessing content types, which matters for user-uploaded files.

Referrer-Policy

Controls what other sites learn from the Referer header, for example same-origin.

Strict-Transport-Security

Tells browsers to use HTTPS only. Send it only on HTTPS responses (usually the proxy terminating TLS does).

Cache-Control: no-store

For pages and responses containing tokens or personal data.

X-Frame-Options

The legacy clickjacking header; frame-ancestors supersedes it, but both are common.

Cookies carry the same idea: the set_cookie family accepts secure, httponly and samesite, and set_signed_cookie passes extra keyword arguments on to it. A session cookie normally gets all three.

class SecureBaseHandler(web.RequestHandler):
    def set_default_headers(self):
        self.set_header("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'")
        self.set_header("X-Content-Type-Options", "nosniff")
        self.set_header("Referrer-Policy", "same-origin")
        self.set_header("Cache-Control", "no-store")


class SessionHandler(SecureBaseHandler):
    def get(self):
        self.set_signed_cookie("session", "alice", httponly=True, secure=True, samesite="Lax")
        self.write("signed in")


async def main():
    app = web.Application([(r"/session", SessionHandler)], cookie_secret=secrets.token_hex(32))
    async with Browser(app) as browser:
        r = await browser.request("GET", "/session")
        for name in ("Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy", "Cache-Control"):
            print(f"{name}: {r.headers[name]}")
        attributes = r.headers["Set-Cookie"].split("; ")[1:]
        print("cookie attributes:", [a for a in attributes if a.lower() in ("secure", "httponly", "samesite=lax")])


asyncio.run(main())

Finally, the stable guide recommends against DNS rebinding by validating the Host header: register the routes with a restrictive host pattern (HostMatches, or Application.add_handlers with a precise regular expression) instead of the default wildcard, and avoid default_host or DefaultHostMatches in a vulnerable application. This matters most for applications bound to localhost, whose cookies and origin checks would otherwise be satisfied by an attacker’s domain that resolves to 127.0.0.1. Routing itself is covered by Routing.

Testing the Protection

Tests should prove both directions: a request without the token is rejected, and a request with it succeeds. With AsyncHTTPTestCase, fetch() returns the response whatever its status; to read the token, request a page that sets the cookie, then replay the cookie and the token. Wrap an expected rejection in ExpectLog, which silences the warning and fails the test if it was not logged.

import unittest

from tornado.testing import AsyncHTTPTestCase, ExpectLog


class XsrfProtectionTest(AsyncHTTPTestCase):
    def get_app(self):
        return make_app()

    def token_and_cookie(self):
        response = self.fetch("/")
        field = re.search(r'name="_xsrf" value="([^"]+)"', response.body.decode()).group(1)
        return field, response.headers["Set-Cookie"].split(";")[0]

    def test_post_without_token_is_forbidden(self):
        with ExpectLog("tornado.general", ".*'_xsrf' argument missing from POST"):
            response = self.fetch("/books", method="POST", body="title=Dune")
        self.assertEqual(response.code, 403)

    def test_post_with_cookie_and_token_succeeds(self):
        field, cookie = self.token_and_cookie()
        response = self.fetch("/books", method="POST", body=urlencode({"title": "Dune", "_xsrf": field}),
                              headers={"Cookie": cookie})
        self.assertEqual(response.code, 200)

    def test_header_token_is_accepted(self):
        field, cookie = self.token_and_cookie()
        response = self.fetch("/books", method="POST", body="title=Dune",
                              headers={"Cookie": cookie, "X-XSRFToken": field})
        self.assertEqual(response.code, 200)

    def test_token_without_cookie_is_forbidden(self):
        field, _ = self.token_and_cookie()
        with ExpectLog("tornado.general", ".*XSRF cookie does not match POST argument"):
            response = self.fetch("/books", method="POST", body=urlencode({"title": "Dune", "_xsrf": field}))
        self.assertEqual(response.code, 403)


outcome = unittest.TextTestRunner(verbosity=0).run(unittest.defaultTestLoader.loadTestsFromTestCase(XsrfProtectionTest))
print("tests run:", outcome.testsRun, "successful:", outcome.wasSuccessful())

Where Older Material Is Outdated

  • xsrf_cookies is no longer the recommended approach for new applications: the development guide calls it intrusive, documents cross_origin_protection as the replacement for 6.6 and announces deprecation warnings from 6.7.

  • get_secure_cookie and set_secure_cookie, used in older tutorials next to xsrf_cookies, were renamed get_signed_cookie and set_signed_cookie in 6.3 (the old names remain as deprecated aliases).

  • WebSocketHandler.check_origin was introduced in 4.0; before that every origin was accepted. Old chat examples that override it with return True are still found everywhere and are unsafe with cookie-based authentication.

  • The X-Requested-With: XMLHttpRequest exemption was removed in 1.1.1 (it was bypassable).

  • xsrf_cookie_name (6.3) makes the __Host- prefix possible; xsrf_cookie_kwargs (4.3) adds cookie attributes.

  • Tornado 6.6 (upcoming) moves check_origin to RequestHandler and adds cross_origin_protection and trusted_origins; the development release notes list them as new features.

Pitfalls Summary

  • Setting xsrf_cookies=True and forgetting the token in one form, one fetch call or one curl script produces a silent 403 whose cause is only in the log.

  • The cookie exists only after something reads self.xsrf_token; a JSON-only front-end must read it explicitly.

  • A JSON body is never searched for _xsrf: use the X-XSRFToken header.

  • Overriding check_xsrf_cookie with pass removes protection from the endpoint; verify another credential instead.

  • Skipping the check for requests that carry an Authorization header is safe only if the handler never reads the session cookie for them.

  • GET is never checked: keep it free of side effects.

  • check_origin returning True on a cookie-authenticated WebSocket enables cross-site hijacking.

  • CORS headers do not protect against XSRF and wildcard-plus-credentials is rejected by browsers; reflect an allow-listed Origin and send Vary: Origin.

  • cross_origin_protection (6.6) works only if the proxy forwards Sec-Fetch-Site, Origin and Host; it is upcoming and unreleased at the time of writing.

  • Cookie attributes (Secure, HttpOnly, SameSite, the __Host- prefix) are layers, not substitutes for the token.