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 |
What the server checks |
A token that is both in the |
The |
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 |
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.
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 thanGET,HEADandOPTIONS. SoPUT,PATCHandDELETEare checked too, and aGEThandler that changes state is unprotected. The built-inErrorHandlerthat answers unmatched routes skips the check, so aPOSTto 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 JSONGETdoes not. A single-page application that never renders a form must readself.xsrf_tokenexplicitly in some handler (for example the one that servesindex.htmlor a "session" endpoint), otherwise everyPOSTis 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
SameSitecookies 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 |
|---|---|---|
|
Enables the check for every request whose method is not |
|
|
Name of the cookie. Added in 6.3 so that the |
|
|
A dictionary passed to |
|
|
Format of newly produced cookies (1 or 2). |
|
|
Enables Fetch Metadata and |
|
|
A list of origins that |
|
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())
Source: Tornado: Application.settings
Customising and Exempting: check_xsrf_cookie
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 sendtext/plainthat 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
POSTis exempt, an attacker can log the visitor in as the attacker. Renderxsrf_form_html()there too. -
State-changing
GET. AGET /books/delete?id=3is never checked. UsePOSTorDELETE. -
Sibling subdomains. A cookie set by
evil.example.comfor the parent domain can overwrite_xsrfonapp.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) andSameSitemitigate this. -
Cookie attributes. Without
xsrf_cookie_kwargsthe cookie is sent withoutSecure,HttpOnlyorSameSite.SameSite=Laxalready stops browsers from sending the cookie on cross-sitePOSTrequests; 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-Policyand careful templates (see Templates & UI Modules) are prerequisites. -
Caching. A page containing a token must not be cached by a shared cache;
Cache-Control: privateorno-storeon 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_cookiewith 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=Truemakes the application reject state-changing requests (anything butGET,HEADandOPTIONS) whenSec-Fetch-Siteis set to something other thansame-originornone. When the header is missing, it comparesOriginwithHostand rejects a mismatch. Requests with neither header are non-browser clients and are always allowed. -
trusted_originsis 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 overridescheck_origin. -
check_origin(origin)moves fromWebSocketHandlertoRequestHandlerin 6.6. By default it tests membership intrusted_origins. It is called only for requests that appear cross-origin, never for known same-origin requests or requests without anOriginheader, and it is always called for WebSocket handshakes. Overriding it to returnTrueexempts a handler (suitable for an endpoint meant to be called from anywhere, and never one that relies on cookies). -
The feature defaults to
Falsein 6.6, is expected to default toTruein 6.7, andxsrf_cookiesstarts 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-Siteis sent by all major browsers since 2023, but only over HTTPS or tolocalhost; plain HTTP deployments rely on theOrigin/Hostfallback. Reverse proxies must passSec-Fetch-Site,OriginandHostthrough unchanged: a proxy that strips both headers disables the protection silently.
# 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
Originonly after matching it against an allow-list, and always sendVary: Originso 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 withAccess-Control-Allow-Credentials: trueis as bad as no protection. -
Answer the preflight
OPTIONSrequest yourself (it is not subject to the XSRF check) and list the custom header that carries the token,X-XSRFToken, inAccess-Control-Allow-Headers; otherwise the browser blocks the real request. -
set_default_headersruns for every response, including error responses written bywrite_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())
Security Headers and Cookie Attributes
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 |
|---|---|
|
Limits where scripts, styles and frames may come from; |
|
Stops browsers from guessing content types, which matters for user-uploaded files. |
|
Controls what other sites learn from the |
|
Tells browsers to use HTTPS only. Send it only on HTTPS responses (usually the proxy terminating TLS does). |
|
For pages and responses containing tokens or personal data. |
|
The legacy clickjacking header; |
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())
Source: Tornado: tornado.testing
Where Older Material Is Outdated
-
xsrf_cookiesis no longer the recommended approach for new applications: the development guide calls it intrusive, documentscross_origin_protectionas the replacement for 6.6 and announces deprecation warnings from 6.7. -
get_secure_cookieandset_secure_cookie, used in older tutorials next toxsrf_cookies, were renamedget_signed_cookieandset_signed_cookiein 6.3 (the old names remain as deprecated aliases). -
WebSocketHandler.check_originwas introduced in 4.0; before that every origin was accepted. Old chat examples that override it withreturn Trueare still found everywhere and are unsafe with cookie-based authentication. -
The
X-Requested-With: XMLHttpRequestexemption 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_origintoRequestHandlerand addscross_origin_protectionandtrusted_origins; the development release notes list them as new features.
Pitfalls Summary
-
Setting
xsrf_cookies=Trueand forgetting the token in one form, onefetchcall or onecurlscript 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 theX-XSRFTokenheader. -
Overriding
check_xsrf_cookiewithpassremoves protection from the endpoint; verify another credential instead. -
Skipping the check for requests that carry an
Authorizationheader is safe only if the handler never reads the session cookie for them. -
GETis never checked: keep it free of side effects. -
check_originreturningTrueon 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
Originand sendVary: Origin. -
cross_origin_protection(6.6) works only if the proxy forwardsSec-Fetch-Site,OriginandHost; 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.