Authentication & Signed Cookies
|
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 on Python 3.13, checked on 2026-10-07. Tornado 6.6 is only available as an
alpha (6.6a1) at the time of writing, so anything described as "6.6" below is upcoming and may still change. Every example
was executed with those versions, through real servers bound to a loopback port or through AsyncHTTPTestCase. The
running example is the Bookshelf Live scenario: a small site where readers sign in to keep a private reading list.
Authentication answers "who is this request from?". Tornado’s answer is deliberately small: a signed cookie, a
current_user property on every handler, and an @authenticated decorator that redirects anonymous users to a login
page. There is no user model, no session store, no password hashing and no login form: those are yours to write, and
this page writes them. Compared with other frameworks, Tornado’s signed cookie plays the role of the Flask session (see
Flask: Sessions, Cookies & Flashing and
Flask: Authentication & Flask-Login) with one important
difference: the cookie is signed but not encrypted, and it is the only state there is, unless you add a server-side store
yourself. For token-based API authentication and the OAuth flows see OAuth 2.0 and OpenID
Connect. Cross-site request forgery (the xsrf_cookies setting) is covered in XSRF & Cross-Origin Protection, the tornado.auth mixins for Google and
Facebook sign-in in Third-party Authentication, and the settings table of Application in
Applications & Request Handlers.
The Login Round Trip
The whole mechanism is a loop between three pieces of code that you own. The login handler checks a password and calls
set_signed_cookie. On every later request the browser sends the cookie back, and get_current_user turns it into a
user object. @authenticated looks at that object and either runs the handler or sends the visitor to the login page.
Signed Cookies: Integrity, Not Confidentiality
RequestHandler.set_signed_cookie and get_signed_cookie store a value in a cookie together with a timestamp and an
HMAC-SHA256 signature computed from the cookie_secret setting. The signature gives the server integrity: a browser,
or anyone who sits between it and the server, cannot change the value, move it to a cookie with a different name or
extend its age without the secret. It gives no confidentiality: the value is only base64 encoded, so anyone who can
read the cookie can read what is inside. Never put a password, a token for another service or personal data into a signed
cookie; put an identifier and look the rest up on the server.
The cookie_secret setting is "a long, random sequence of bytes", in the words of the documentation. Generate it once,
store it outside the source code (an environment variable or a secret manager) and read it at start-up. The examples on
this page generate one with secrets.token_hex when no environment variable is set, which is fine for tests and
demonstrations but would invalidate every session at each restart in production. Signing a cookie with no secret
configured raises an error, so a missing setting is a loud failure, not a silent one.
The first example is the harness that the rest of the page reuses. serve starts an application on a free loopback
port, and Browser behaves like a very small cookie jar: it remembers the cookies a response sets, sends them back, and
forgets those that the server clears. Redirects are not followed so that the responses can be inspected.
import asyncio
import contextlib
import os
import secrets
from http.cookies import SimpleCookie
from tornado import httpserver, netutil, web
from tornado.httpclient import AsyncHTTPClient
COOKIE_SECRET = os.environ.get("BOOKSHELF_COOKIE_SECRET") or secrets.token_hex(32)
class Browser:
"""A minimal cookie jar on top of AsyncHTTPClient."""
def __init__(self, base):
self.base = base
self.jar = {}
async def request(self, method, path, **options):
headers = dict(options.pop("headers", {}))
if self.jar:
headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in self.jar.items())
if method in ("POST", "PUT", "PATCH", "DELETE") and "body" not in options:
options["body"] = ""
response = await AsyncHTTPClient().fetch(
self.base + path, method=method, headers=headers,
follow_redirects=False, raise_error=False, **options,
)
for line in response.headers.get_list("Set-Cookie"):
parsed = SimpleCookie()
parsed.load(line)
for name, morsel in parsed.items():
if morsel.value:
self.jar[name] = morsel.value
else:
self.jar.pop(name, None) # cleared by the server
return response
def get(self, path, **options):
return self.request("GET", path, **options)
def post(self, path, **options):
return self.request("POST", path, **options)
@contextlib.asynccontextmanager
async def serve(app):
server = httpserver.HTTPServer(app)
sockets = netutil.bind_sockets(0, "127.0.0.1")
server.add_sockets(sockets)
try:
yield Browser(f"http://127.0.0.1:{sockets[0].getsockname()[1]}")
finally:
server.stop()
await server.close_all_connections()
def show(response):
body = response.body.decode(errors="replace").strip()
location = response.headers.get("Location")
print(response.code, response.reason, f"-> {location}" if location else "", "|", body[:80])
The next example sets and reads a signed cookie, and then abuses it the way an attacker would: it decodes the payload to
show that it is readable, tampers with the value, and moves the value to a cookie of another name. get_signed_cookie
returns the value as bytes when the signature, the name and the age are valid, and None otherwise. It never raises
for a bad cookie, so a forged or expired cookie is simply an anonymous visitor.
import base64
class WelcomeHandler(web.RequestHandler):
def get(self):
self.set_signed_cookie("reader", "ana") # lasts 30 days in the browser by default
self.set_signed_cookie("favourite", "dune")
self.write("welcome")
class WhoHandler(web.RequestHandler):
def get(self):
reader = self.get_signed_cookie("reader") # bytes, or None when signature, name or age is not valid
self.write({"reader": reader.decode() if reader else None})
async def main():
app = web.Application([(r"/welcome", WelcomeHandler), (r"/who", WhoHandler)], cookie_secret=COOKIE_SECRET)
async with serve(app) as browser:
response = await browser.get("/welcome")
raw = browser.jar["reader"].strip('"')
print("Set-Cookie lines:", len(response.headers.get_list("Set-Cookie")))
print("cookie value :", raw[:40] + "...")
fields = raw.split("|")
print("fields :", len(fields), "(version, key version, timestamp, name, base64 value, signature)")
print("payload decoded :", base64.b64decode(fields[4].split(":", 1)[1])) # readable: signed, not encrypted
print("--- valid cookie")
show(await browser.get("/who"))
print("--- tampered value (payload changed, signature kept)")
forged = base64.b64encode(b"admin").decode()
fields[4] = f"{len(forged)}:{forged}"
browser.jar["reader"] = "|".join(fields)
show(await browser.get("/who"))
print("--- value of the favourite cookie moved to the reader cookie")
browser.jar["reader"] = browser.jar["favourite"]
show(await browser.get("/who"))
asyncio.run(main())
Output (the timestamp and the signature differ on every run):
Set-Cookie lines: 2
cookie value : 2|1:0|10:1791386947|6:reader|4:YW5h|08f8...
fields : 6 (version, key version, timestamp, name, base64 value, signature)
payload decoded : b'ana'
--- valid cookie
200 OK | {"reader": "ana"}
--- tampered value (payload changed, signature kept)
200 OK | {"reader": null}
--- value of the favourite cookie moved to the reader cookie
200 OK | {"reader": null}
A common slip is to pass a default as the second argument, as get_cookie allows. get_signed_cookie takes the cookie
name and, as an optional second argument, a value to decode instead of reading the request (see
Signed Values Outside Cookies). It has no default: test the result for None.
Lifetimes: expires_days and max_age_days
Two independent clocks protect a signed cookie, and mixing them up is a classic bug:
| Argument | Used by | Meaning |
|---|---|---|
|
|
The lifetime of the cookie in the browser (the |
|
|
How old the signed timestamp may be before the server rejects the value. The default is 31 days. It is enforced on
the server, so it works even when a client ignores |
|
|
The |
|
|
Signature format. Version 2 is the default for new cookies; version 1 values are still accepted unless you pass
|
A server-side age check matters because the timestamp is part of the signed value: a stolen cookie stops working after
max_age_days even if the thief rewrites its expiry. For a login cookie pick the shortest period your users will
tolerate (hours for a banking site, days for a reading list) and treat it as an absolute lifetime: Tornado does not
slide the timestamp forward by itself. To implement an idle timeout, set the cookie again on activity.
The methods wrap two module-level functions in tornado.web, create_signed_value and decode_signed_value. They exist
in Tornado 6.5.10 and accept a clock argument, which makes expiry easy to demonstrate without waiting, but the
official reference does not list them, so treat them as implementation helpers for demonstrations and tests rather than
as a stable API; production code should call the RequestHandler methods.
import time
from tornado.web import create_signed_value, decode_signed_value
secret = COOKIE_SECRET
signed = create_signed_value(secret, "reader", "ana")
now = time.time()
for label, days in (("today", 0), ("after 30 days", 30), ("after 40 days", 40)):
value = decode_signed_value(secret, "reader", signed, max_age_days=31, clock=lambda: now + days * 86400)
print(f"{label:14} max_age_days=31 ->", value)
print("after 40 days max_age_days=60 ->",
decode_signed_value(secret, "reader", signed, max_age_days=60, clock=lambda: now + 40 * 86400))
print("version 1 value accepted by default:",
decode_signed_value(secret, "reader", create_signed_value(secret, "reader", "ana", version=1)),
"| with min_version=2:",
decode_signed_value(secret, "reader", create_signed_value(secret, "reader", "ana", version=1), min_version=2))
Source: Tornado: RequestHandler.create_signed_value (the module-level helpers are read from the 6.5.10 source)
today max_age_days=31 -> b'ana'
after 30 days max_age_days=31 -> b'ana'
after 40 days max_age_days=31 -> None
after 40 days max_age_days=60 -> b'ana'
version 1 value accepted by default: b'ana' | with min_version=2: None
Signed Values Outside Cookies
RequestHandler.create_signed_value(name, value, version=None) signs a string without storing it in a cookie, and
get_signed_cookie(name, value) verifies a value that you pass in. Together they cover short-lived tokens that travel in
a URL or a hidden field, such as an e-mail confirmation link. The signature includes the name, so a token created for
"confirm" is rejected when verified as "reset". Remember that such a token is readable, expires only through
max_age_days, and cannot be revoked before then, so keep them short-lived and single-purpose.
Cookie Attributes You Must Set
set_signed_cookie forwards every extra keyword argument to set_cookie, and the defaults of set_cookie are
permissive: no HttpOnly, no Secure and no SameSite. A signed cookie that carries a login must set them.
| Argument | Effect |
|---|---|
|
JavaScript cannot read the cookie, which limits what a cross-site scripting bug can steal. Keep it on for login cookies. |
|
The browser sends the cookie only over HTTPS. Required in production; behind a TLS-terminating proxy enable it anyway and make sure the proxy forwards the original scheme. |
|
Restricts when the cookie is sent on cross-site requests, which blunts cross-site request forgery but does not replace
|
|
Scope of the cookie. Prefer the narrowest scope, and never share a login cookie across sibling subdomains that you do not control. |
|
Lifetime in the browser, see Lifetimes: expires_days and max_age_days. |
The prefix __Host- on the cookie name adds a browser-enforced rule (the cookie must be Secure, have Path=/ and no
Domain), which is the strictest scope available. The next example sets a login cookie with every attribute and prints
the resulting header.
class AttributesHandler(web.RequestHandler):
def get(self):
self.set_signed_cookie(
"__Host-reader", "ana",
expires_days=None, # session cookie; the server still enforces max_age_days
httponly=True, secure=True, samesite="Lax", path="/",
)
self.write("ok")
async def main():
app = web.Application([(r"/", AttributesHandler)], cookie_secret=COOKIE_SECRET)
async with serve(app) as browser:
response = await browser.get("/")
header = response.headers.get_list("Set-Cookie")[0]
name, _, rest = header.partition("=")
print(name, "= <signed value>;", rest.split(";", 1)[1].strip())
asyncio.run(main())
__Host-reader = <signed value>; HttpOnly; Path=/; SameSite=Lax; Secure
Cookies set during a request are not visible to get_signed_cookie in the same request: it reads what the browser
sent. The same is true of get_cookie. Code that sets a cookie and then reads it back in the same handler sees None.
Rotating cookie_secret
A secret that never changes is a liability: leaking it lets an attacker forge any user. Rotation needs a way to accept
cookies signed by the old key while new cookies use the new one. Tornado supports this directly: cookie_secret may be a
dictionary that maps an integer key version to a secret, and the key_version setting chooses the key used to sign new
cookies. Verification looks at the version recorded inside each cookie, so old cookies keep working until you remove their
key from the dictionary.
| Setting or method | Meaning |
|---|---|
|
A string or bytes secret, or a dictionary of key versions to secrets. |
|
Required when |
|
Returns the key version recorded in a cookie, or |
The rotation procedure is therefore: add a new key to the dictionary and switch key_version to it, re-sign cookies as
users come back (a handler can notice that get_signed_cookie_key_version is not the current version and call
set_signed_cookie again), and after the longest cookie lifetime has passed delete the old key. The example simulates
the three phases with three applications.
OLD_KEY, NEW_KEY = secrets.token_hex(32), secrets.token_hex(32)
CURRENT = {}
class ProfileHandler(web.RequestHandler):
def get(self):
name = self.get_signed_cookie("reader")
if name is None:
self.set_status(401)
self.write({"reader": None})
return
key_version = self.get_signed_cookie_key_version("reader")
if key_version != self.settings["key_version"]:
self.set_signed_cookie("reader", name) # re-sign with the current key
self.write({"reader": name.decode(), "cookie_key_version": key_version})
class LoginHandler(web.RequestHandler):
def get(self):
self.set_signed_cookie("reader", "ana")
self.write("signed in")
def make_app(secrets_by_version, key_version):
return web.Application(
[(r"/login", LoginHandler), (r"/profile", ProfileHandler)],
cookie_secret=secrets_by_version, key_version=key_version,
)
async def main():
async with serve(make_app({1: OLD_KEY}, 1)) as browser:
await browser.get("/login")
cookie = browser.jar["reader"]
print("phase 1: cookie signed with key version 1")
async with serve(make_app({1: OLD_KEY, 2: NEW_KEY}, 2)) as browser:
browser.jar["reader"] = cookie
print("phase 2 (both keys, new cookies use 2):")
show(await browser.get("/profile")) # accepted, key version 1, re-signed with 2
show(await browser.get("/profile")) # now carries key version 2
async with serve(make_app({2: NEW_KEY}, 2)) as browser:
browser.jar["reader"] = cookie
print("phase 3 (old key removed):")
show(await browser.get("/profile")) # the old cookie is now rejected
asyncio.run(main())
phase 1: cookie signed with key version 1
phase 2 (both keys, new cookies use 2):
200 OK | {"reader": "ana", "cookie_key_version": 1}
200 OK | {"reader": "ana", "cookie_key_version": 2}
phase 3 (old key removed):
401 Unauthorized | {"reader": null}
Because the dictionary form needs version 2 values, a cookie in the old version 1 format cannot be validated by a dictionary secret. If you still have version 1 cookies in the wild, upgrade them with a single-secret deployment first.
Knowing Who Is Calling: get_current_user
Every RequestHandler has a current_user property. The first time it is read, Tornado calls the overridable method
get_current_user() and caches the result for the rest of the request. The method returns any object you like (None
meaning "nobody"), and the value is also available in templates as current_user. The usual implementation puts the
parsing of the signed cookie in a base class that every handler of the site inherits from.
import json
USERS = {"ana": {"name": "ana", "reading_list": ["Dune", "Hyperion"]}}
class BaseHandler(web.RequestHandler):
def get_current_user(self):
cookie = self.get_signed_cookie("reader", max_age_days=1)
return USERS.get(cookie.decode()) if cookie else None
class ReadingListHandler(BaseHandler):
@web.authenticated
def get(self):
self.write({"reader": self.current_user["name"], "list": self.current_user["reading_list"]})
@web.authenticated
def post(self):
self.current_user["reading_list"].append(self.get_body_argument("title"))
self.write({"added": True})
class LoginHandler(BaseHandler):
def get(self):
self.write("login form goes here")
def post(self):
name = self.get_body_argument("name", "")
if name not in USERS:
raise web.HTTPError(403)
self.set_signed_cookie("reader", name, httponly=True, samesite="Lax", expires_days=None)
self.redirect(self.get_argument("next", "/list")) # see the open redirect warning below
class LogoutHandler(BaseHandler):
def post(self):
self.clear_cookie("reader", httponly=True, samesite="Lax")
self.redirect("/login")
def make_app():
return web.Application(
[(r"/login", LoginHandler), (r"/logout", LogoutHandler), (r"/list", ReadingListHandler)],
cookie_secret=COOKIE_SECRET, login_url="/login",
)
async def main():
async with serve(make_app()) as browser:
print("anonymous GET /list:"); show(await browser.get("/list"))
print("anonymous POST /list:"); show(await browser.post("/list", body="title=Emma"))
print("log in:"); show(await browser.post("/login?next=/list", body="name=ana"))
print("GET /list:"); show(await browser.get("/list"))
print("log out:"); show(await browser.post("/logout"))
print("GET /list again:"); show(await browser.get("/list"))
asyncio.run(main())
anonymous GET /list:
302 Found -> /login?next=%2Flist |
anonymous POST /list:
403 Forbidden | <html><title>403: Forbidden</title><body>403: Forbidden</body></html>
log in:
302 Found -> /list |
GET /list:
200 OK | {"reader": "ana", "list": ["Dune", "Hyperion"]}
log out:
302 Found -> /login |
GET /list again:
302 Found -> /login?next=%2Flist |
Several details are visible in that output and are worth remembering.
-
The base-class pattern keeps authentication in one place. A handler that forgets
@authenticatedis public: there is no deny-by-default switch in Tornado, so review every handler, or check the user inprepareof the base class and opt out for public pages. -
The signed cookie stores the user’s name, not the user object. The
USERSlookup happens on every request, so a deleted or disabled user loses access immediately. This is the opposite of storing roles or flags in the cookie, where a change takes effect only after the cookie expires. -
get_current_usermay not be a coroutine. Tornado calls it synchronously the first timecurrent_useris read, so it must not block: a dictionary lookup is fine, a database query with a synchronous driver is not (see Asynchronous I/O & the Event Loop).
Loading the User Asynchronously in prepare
When the user has to be loaded with await (an asynchronous database driver, a call to a session service), do not use
get_current_user. current_user is also a plain settable property, and prepare may be a coroutine. Assign the
property in prepare, and the rest of the request, including @authenticated, sees it.
class FakeUserStore:
"""Stands in for an asynchronous database driver."""
async def find(self, name):
await asyncio.sleep(0) # a real driver awaits the network here
return USERS.get(name)
store = FakeUserStore()
class AsyncBaseHandler(web.RequestHandler):
async def prepare(self):
cookie = self.get_signed_cookie("reader", max_age_days=1)
self.current_user = await store.find(cookie.decode()) if cookie else None
class AsyncReadingListHandler(AsyncBaseHandler):
@web.authenticated
async def get(self):
self.write({"reader": self.current_user["name"], "async": True})
async def main():
app = web.Application([(r"/list", AsyncReadingListHandler), (r"/login", LoginHandler)],
cookie_secret=COOKIE_SECRET, login_url="/login")
async with serve(app) as browser:
show(await browser.get("/list"))
await browser.post("/login", body="name=ana")
show(await browser.get("/list"))
asyncio.run(main())
302 Found -> /login?next=%2Flist |
200 OK | {"reader": "ana", "async": true}
The official documentation shows the same pattern with the legacy @gen.coroutine decorator; a native async def does
the same thing (see Coroutines & asyncio). If a subclass of
AsyncBaseHandler overrides prepare, it must call await super().prepare(), or the user is never loaded and every
request looks anonymous. When prepare runs, the XSRF check has already happened, which matters for the
XSRF & Cross-Origin Protection page.
@authenticated and login_url
tornado.web.authenticated decorates a handler method. When self.current_user is falsy:
-
for
GETandHEADit redirects to the login URL, adding?next=followed by the original request URI, unless the login URL already contains a?(then Tornado assumes you know what you are doing and uses it as it is); -
for every other method (
POST,PUT,PATCH,DELETE) it raisesHTTPError(403), because redirecting a form submission to a login page would silently drop the body.
The login URL comes from the login_url application setting, or from overriding RequestHandler.get_login_url() when it
depends on the request (a different login page per host or per locale, for example). Using @authenticated without
either raises an error saying that the setting is required. That explains the two lines in the earlier output: an
anonymous GET /list was redirected with a next parameter and an anonymous POST received a 403.
The decorator is for handler methods. For API endpoints that clients call with fetch or a mobile app, a redirect to an
HTML login page is the wrong answer; override get_login_url or, better, make the base class send a 401:
class ApiHandler(BaseHandler):
def get_login_url(self):
# Called by @authenticated for GET and HEAD: answer with JSON instead of redirecting.
raise web.HTTPError(401, reason="Unauthorized")
def write_error(self, status_code, **kwargs):
self.set_header("Content-Type", "application/json")
self.finish({"status": status_code})
class ApiReadingListHandler(ApiHandler):
@web.authenticated
def get(self):
self.write({"list": self.current_user["reading_list"]})
@web.authenticated
def post(self):
self.write({"saved": True})
async def main():
app = web.Application([(r"/api/list", ApiReadingListHandler)], cookie_secret=COOKIE_SECRET)
async with serve(app) as browser:
show(await browser.get("/api/list"))
show(await browser.post("/api/list"))
asyncio.run(main())
401 Unauthorized | {"status": 401}
403 Forbidden | {"status": 403}
The next Parameter and Open Redirects
The next parameter makes the login page return the visitor to where they were going, and it is user-controlled input.
A login handler that executes self.redirect(self.get_argument("next")) lets an attacker send victims a link such as
/login?next=https://evil.example/, which, after a legitimate login, bounces them to a look-alike site. Tornado’s
redirect does not restrict the target. Accept only local paths:
from urllib.parse import urlsplit
def safe_next(target, default="/list"):
"""Return target only when it is a local absolute path."""
parts = urlsplit(target)
if parts.scheme or parts.netloc or not target.startswith("/") or target.startswith(("//", "/\\")):
return default
return target
for candidate in ("/list?page=2", "https://evil.example/", "//evil.example/", "/\\evil.example", "list", ""):
print(repr(candidate).ljust(24), "->", safe_next(candidate))
'/list?page=2' -> /list?page=2
'https://evil.example/' -> /list
'//evil.example/' -> /list
'/\\evil.example' -> /list
'list' -> /list
'' -> /list
Use safe_next(self.get_argument("next", "")) in the login handler instead of the unchecked call in the earlier example.
Hashing Passwords Without Blocking the Loop
Passwords are stored as the output of a deliberately slow, salted, memory-hard function, never as plain text or a fast
hash such as SHA-256. Python’s standard library provides hashlib.scrypt (and hashlib.pbkdf2_hmac); the third-party
argon2-cffi and bcrypt packages are common alternatives and are used the same way. The salt is random per password,
the comparison is constant-time (hmac.compare_digest), and the stored string records the parameters so that they can
be raised later.
The slow part is the catch in Tornado. A single scrypt call takes tens of milliseconds of CPU, and Tornado handles all
connections on one thread: while a handler hashes, every other request waits. The fix is the executor pattern from
Asynchronous I/O & the Event Loop: run the hash in a
thread (hashlib.scrypt and bcrypt release the GIL while they work, so threads give real parallelism) with
IOLoop.current().run_in_executor(None, function, *args), or asyncio.to_thread, and await the result.
import base64
import hashlib
import hmac
import time
from tornado.ioloop import IOLoop
N, R, P = 2 ** 14, 8, 1 # scrypt cost parameters; raise them as hardware gets faster
def hash_password(password):
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(password.encode(), salt=salt, n=N, r=R, p=P, dklen=32)
return f"scrypt${N}${R}${P}${base64.b64encode(salt).decode()}${base64.b64encode(digest).decode()}"
def verify_password(password, stored):
_, n, r, p, salt, digest = stored.split("$")
candidate = hashlib.scrypt(password.encode(), salt=base64.b64decode(salt), n=int(n), r=int(r), p=int(p), dklen=32)
return hmac.compare_digest(candidate, base64.b64decode(digest))
ACCOUNTS = {}
class RegisterHandler(web.RequestHandler):
async def post(self):
name, password = self.get_body_argument("name"), self.get_body_argument("password")
ACCOUNTS[name] = await IOLoop.current().run_in_executor(None, hash_password, password)
self.set_status(201)
self.write({"registered": name})
class PasswordLoginHandler(web.RequestHandler):
async def post(self):
name, password = self.get_body_argument("name", ""), self.get_body_argument("password", "")
stored = ACCOUNTS.get(name) or DUMMY_HASH # same work for unknown users: no timing hint
valid = await IOLoop.current().run_in_executor(None, verify_password, password, stored)
if not (valid and name in ACCOUNTS):
raise web.HTTPError(403) # one answer for "no such user" and "wrong password"
self.set_signed_cookie("reader", name, httponly=True, samesite="Lax", expires_days=None)
self.write({"login": "ok"})
DUMMY_HASH = hash_password(secrets.token_hex(8))
async def main():
app = web.Application([(r"/register", RegisterHandler), (r"/login", PasswordLoginHandler),
], cookie_secret=COOKIE_SECRET)
async with serve(app) as browser:
# Pick a throwaway test password; real ones come from the sign-up form.
demo_password = secrets.token_urlsafe(12)
show(await browser.post("/register", body=f"name=ana&password={demo_password}"))
print("stored form:", ACCOUNTS["ana"].split("$")[0], "with cost n =", N)
ticker = []
async def tick(): # proves the loop stays responsive
while True:
ticker.append(time.perf_counter())
await asyncio.sleep(0.005)
task = asyncio.create_task(tick())
start = time.perf_counter()
wrong, right = await asyncio.gather(
browser.post("/login", body=f"name=ana&password=wrong-{demo_password}"),
browser.post("/login", body=f"name=ana&password={demo_password}"),
)
task.cancel()
print("wrong password:", wrong.code, "| right password:", right.code)
gaps = [b - a for a, b in zip(ticker, ticker[1:])]
print("loop stayed responsive:", max(gaps) < 0.05, f"(largest gap {max(gaps) * 1000:.0f} ms)")
show(await browser.post("/login", body="name=nobody&password=x"))
asyncio.run(main())
201 Created | {"registered": "ana"}
stored form: scrypt with cost n = 16384
wrong password: 403 | right password: 200
loop stayed responsive: True (largest gap 9 ms)
403 Forbidden | <html><title>403: Forbidden</title><body>403: Forbidden</body></html>
Three details of that handler matter beyond the executor. The dummy hash makes a login for an unknown user take as long as one for a known user, so response time does not reveal which accounts exist. Both failure cases give the same answer. And the cost parameters live inside the stored string, so you can raise them later and re-hash a password the next time its owner logs in successfully. Production code should also throttle failed logins per account and per address (a counter in Redis, see Redis) and require a second factor for sensitive accounts; see Authentication Methods, 2FA & Passwordless.
Logging Out and Revoking Sessions
clear_cookie(name) sends a cookie with an empty value and an expiry in the past. Since Tornado 6.3 it accepts the same
keyword arguments as set_cookie (except expires and max_age), and a cookie can only be cleared when the domain,
path and, for SameSite=None cookies, the samesite and secure arguments match the ones used when it was set. If the
arguments differ the browser keeps the old cookie, and the user appears to stay logged in. The earlier logout handler
repeats the attributes of the login cookie for exactly that reason. Avoid clear_all_cookies: it is deprecated since
6.3, because the cookie rules are too complex for a method that only knows cookie names.
Clearing the cookie logs the browser out, but a signed cookie is a bearer credential: anyone who copied it can present it
again until max_age_days runs out. A real logout, a password change or an account suspension therefore needs
server-side state. The usual designs are:
-
Keep only a random session identifier in the signed cookie and store the session (user, creation time, expiry) in Redis or a database; logout deletes the row. The cookie then proves integrity, and the store is authoritative.
-
Keep the user name in the cookie and add a per-user "session version" or "password changed at" value in the database;
get_current_usercompares it with a copy kept in the cookie and rejects mismatches. -
Use short
max_age_daysvalues and re-issue the cookie on activity, accepting that revocation is delayed.
Renew the cookie after login (set_signed_cookie always issues a new value, so a pre-login identifier is not carried
over), which prevents session fixation. Tornado does not keep a server-side session table, so there is nothing to
invalidate unless you build one.
Testing Authenticated Handlers
tornado.testing.AsyncHTTPTestCase runs the application on a loopback port and gives self.fetch. To test a protected
page you do not have to drive the login form: build the cookie yourself with tornado.web.create_signed_value and the
same secret, which also lets a test forge an expired or tampered cookie. The module-level function accepts a custom
clock (see the note on these helpers in the lifetimes section), so you can create a cookie that is already too old.
import time
import unittest
from tornado.testing import AsyncHTTPTestCase
from tornado.web import create_signed_value
class AuthTests(AsyncHTTPTestCase):
def get_app(self):
return make_app() # the application with BaseHandler, @authenticated and login_url
def cookie(self, name="ana", age_days=0):
signed = create_signed_value(COOKIE_SECRET, "reader", name, clock=lambda: time.time() - age_days * 86400)
return {"Cookie": "reader=" + signed.decode()}
def test_anonymous_get_redirects_with_next(self):
response = self.fetch("/list", follow_redirects=False)
self.assertEqual(response.code, 302)
self.assertEqual(response.headers["Location"], "/login?next=%2Flist")
def test_authenticated_get(self):
response = self.fetch("/list", headers=self.cookie())
self.assertEqual(response.code, 200)
def test_expired_cookie_is_anonymous(self):
response = self.fetch("/list", headers=self.cookie(age_days=40), follow_redirects=False)
self.assertEqual(response.code, 302)
def test_unknown_user_is_anonymous(self):
response = self.fetch("/list", headers=self.cookie(name="mallory"), follow_redirects=False)
self.assertEqual(response.code, 302)
suite = unittest.defaultTestLoader.loadTestsFromTestCase(AuthTests)
result = unittest.TextTestRunner(verbosity=0).run(suite)
print("tests run:", result.testsRun, "| failures:", len(result.failures), "| errors:", len(result.errors))
tests run: 4 | failures: 0 | errors: 0
In the test above get_app reuses make_app() from the get_current_user example, whose handlers read the cookie with
max_age_days=1, which is why a 40-day-old cookie is treated as anonymous.
Old Names and Code You Will Meet
Tornado 6.3 renamed the signed-cookie methods because "secure" already means something else for cookies (the Secure
attribute and the __Secure- prefix). The old names remain as aliases and still work in 6.5.10, but new code should use
the new names, and a codebase search for the old ones is a quick way to find legacy authentication code.
| Old name (alias, still works) | Current name |
|---|---|
|
|
|
|
|
|
Older tutorials also use @tornado.gen.coroutine in prepare (replace it with async def) and put the login form in
the same handler as the protected page. Another pattern in the official demos stores a JSON-encoded user object in the
cookie, which works but exposes its contents and makes changes to the user effective only after the cookie expires; prefer
an identifier. The tornado.auth mixins (Google, Facebook and generic OAuth 2.0 sign-in) build on exactly the machinery
of this page: after the provider has authenticated the user, your callback calls set_signed_cookie and redirects, and
they are covered in Third-party Authentication.
What Changed Recently
| Version | Change |
|---|---|
3.2.1 |
Signed values version 2, with a key version field, became the default format, and |
6.3 |
|
6.5.10 |
The version this page was verified against; the signed-cookie methods behave as described for the names introduced in 6.3. |
6.6 (upcoming) |
Not released at the time of writing (alpha 6.6a1). A |
Security Checklist
-
Generate
cookie_secretrandomly (at least 32 random bytes), keep it out of version control, load it from the environment or a secret manager, and rotate it with a dictionary andkey_version. -
Remember that a signed cookie can be read by its holder: store an identifier, never secrets or sensitive data.
-
Set
httponly=True,secure=Trueandsamesite="Lax"(or"Strict") on the login cookie, and consider the__Host-prefix. -
Choose
max_age_daysdeliberately and keep a server-side way to revoke sessions that matter. -
Load the user from the identifier on each request, so disabled or deleted users lose access at once.
-
Hash passwords with scrypt, Argon2 or bcrypt, in an executor, with a constant-time comparison and a uniform failure.
-
Enable
xsrf_cookiesfor every site that authenticates with cookies, because the browser sends the cookie on cross-site requests; signing prevents forgery of the cookie, not forgery of the request. -
Validate the
nextparameter before redirecting, and serve the site only over HTTPS. -
Review every handler for a missing
@authenticated: Tornado denies nothing by default.
Pitfalls Summary
-
get_signed_cookiereturnsbytes(decode them) andNonefor anything invalid; it takes no default. -
get_signed_cookiedoes not see a cookie that was set earlier in the same request. -
expires_daysis the browser’s lifetime,max_age_daysthe server’s; they are independent. -
get_current_usercannot be a coroutine; assignself.current_userin an asyncprepareinstead, and callsuper().prepare()from subclasses. -
@authenticatedredirectsGETandHEADbut answers403for other methods, and needslogin_urlor an overriddenget_login_url. -
A dictionary
cookie_secretwithoutkey_versionfails when the first cookie is signed. -
clear_cookiemust repeat thedomain,path,samesiteandsecureof the original cookie. -
Unchecked
nextis an open redirect; password hashing on the event loop stalls every connection.