Third-party Authentication

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" or "6.7" below is upcoming and may still change. Every Python example was executed with those versions; the identity provider is faked by a local server bound to a loopback port, so no real provider, account or network access is involved. The running example is the Bookshelf Live scenario: a reader signs in with a Google account, and the handlers that serve the live shelf recognise the user through a signed cookie.

"Third-party authentication" means letting another service (Google, Facebook and so on) prove who the user is, instead of storing and checking a password yourself. Tornado ships a small module for it, tornado.auth, made of mixins: classes that you add to a RequestHandler to give it the methods that talk to one provider. This is much thinner than the Authlib-based approach described in Flask: OAuth, Social Login & LDAP: the mixins build the authorization URL and exchange the code for a token, and nothing else. Sessions, the signed cookie that remembers the user afterwards, user records, token storage and the protections against login CSRF are your code. The signed-cookie mechanics (cookie_secret, set_signed_cookie, get_current_user, @authenticated) are the topic of Authentication & Signed Cookies; this page uses them in the smallest possible form. For the protocol itself (grants, scopes, tokens) see OAuth 2.0 and OpenID Connect on this site and, specifically, Authorization Code and PKCE.

The tornado.auth Module

All classes in tornado.auth follow one usage pattern, described by the module documentation: on a login handler you call authorize_redirect() or authenticate_redirect() to send the browser to the provider, and later get_authenticated_user() to complete the sign-in; on any other handler you call a provider-specific request method such as facebook_request() or the generic oauth2_request() with the stored token. The module is built to be extended: the abstract base mixins read their configuration from class attributes (_OAUTH_AUTHORIZE_URL, _OAUTH_ACCESS_TOKEN_URL), which is how you add a provider that Tornado does not know.

Class Protocol Status in Tornado 6.5.10

OAuth2Mixin

OAuth 2.0 (abstract base)

Current. authorize_redirect() and oauth2_request(); it has no get_authenticated_user() of its own.

GoogleOAuth2Mixin

OAuth 2.0 with Google

Current (since 3.2). Adds get_google_oauth_settings() and get_authenticated_user().

FacebookGraphMixin

OAuth 2.0 with the Facebook Graph API

Current. Adds get_authenticated_user() and facebook_request(). See the caveat below.

OAuthMixin

OAuth 1.0 and 1.0a (abstract base)

Legacy. No service that needs it still exists in the module except Twitter.

TwitterMixin

OAuth 1.0a with the Twitter API 1.1

Deprecated since 6.3 (the API it targets was retired); it will no longer be updated and will be removed.

OpenIdMixin

OpenID 2.0 and Attribute Exchange

Documented as deprecated since 6.6 (OpenID 2.0 is no longer widely supported); to be removed in 6.7. The 6.5.10 code already emits a DeprecationWarning when an instance is created.

Two remarks help to read this table. First, "OpenID" here is the old OpenID 2.0 protocol, which has nothing to do with OpenID Connect; tornado.auth has no OpenID Connect support. Second, OAuth2Mixin.authorize_redirect() still accepts a client_secret argument, but it never had any effect and has been deprecated since 6.4 (removal in 7.0): the authorization redirect never carries the secret, so do not pass it.

The Authorization-code Flow

The mixins implement the authorization code grant: the browser leaves for the provider, the user consents, and the provider sends the browser back to your callback URL with a one-time code, which your server then exchanges for an access token over a direct (server to server) request. In Tornado both the redirect to the provider and the callback are usually handled by the same handler, branching on whether the request carries a code:

sequenceDiagram participant B as Browser participant T as Tornado login handler participant P as Provider B->>T: GET /login/google T->>T: state = random value, store in a signed cookie T-->>B: 302 to provider with client_id, redirect_uri, scope, state B->>P: user signs in and consents P-->>B: 302 to /login/google with code and state B->>T: GET /login/google with code and state T->>T: compare state with the cookie T->>P: get_authenticated_user (POST code and client secret) P-->>T: access_token T->>P: oauth2_request to the userinfo URL P-->>T: user profile T-->>B: 302 to / with signed user cookie
Figure 1. The sign-in flow of the Bookshelf Live login handler. Only the authorize redirect and the token request are provided by the mixin; the state check and the cookie are application code.

The methods you will call, with the exact signatures of 6.5.10:

Method What it does

authorize_redirect(redirect_uri=None, client_id=None, client_secret=None, extra_params=None, scope=None, response_type="code")

Synchronous (not awaited since 6.0). Builds the provider’s authorization URL from _OAUTH_AUTHORIZE_URL and the arguments (scope is a list joined with spaces, extra_params is added to the query) and calls redirect().

get_authenticated_user(redirect_uri, code, client_id=None, client_secret=None) (Google)

A coroutine. POSTs the code to the token URL with grant_type=authorization_code and returns the decoded JSON, containing access_token. It does not return user details. When client_id and client_secret are omitted they come from get_google_oauth_settings().

oauth2_request(url, access_token=None, post_args=None, **args)

A coroutine. Fetches a URL with the access token appended as an access_token query parameter (a POST when post_args is given) and returns the decoded JSON.

get_auth_http_client()

Returns the AsyncHTTPClient used for the provider requests; override it to use another client, a proxy or a test double.

The redirect_uri you pass must be byte for byte the one registered at the provider, which is why the mixin documentation advises building it from configuration (a redirect_base_uri setting plus reverse_url) instead of from self.request.full_url(), which depends on the Host header and on any proxy in front of the server.

Configuration and Secrets

GoogleOAuth2Mixin reads the client credentials from an application setting named google_oauth, a dictionary with the keys key (the client id) and secret (the client secret). The method get_google_oauth_settings() returns that dictionary and exists so you can override it to read the credentials from somewhere else, for example a secret store or a database row per tenant. You create the credentials in the Google Cloud console as an OAuth client ID of type Web application, and register the exact redirect URI there. While the application is in "testing" mode, Google also requires the accounts that may sign in to be listed as test users.

The client secret is a credential, so it must never be in the source tree. The example reads everything from the environment and, for local experiments only, generates throwaway values:

import hmac
import os
import secrets
from urllib.parse import urljoin

from tornado import web
from tornado.auth import FacebookGraphMixin, GoogleOAuth2Mixin


def load_settings() -> dict:
    """Credentials come from the environment, never from source control."""
    return {
        "cookie_secret": os.environ.get("BOOKSHELF_COOKIE_SECRET") or secrets.token_hex(32),
        "login_url": "/login/google",
        "redirect_base_uri": os.environ.get("BOOKSHELF_BASE_URI", "http://localhost:8888"),
        "cookie_secure": os.environ.get("BOOKSHELF_COOKIE_SECURE", "0") == "1",
        "google_oauth": {
            "key": os.environ.get("GOOGLE_CLIENT_ID", "local-client-id"),
            "secret": os.environ.get("GOOGLE_CLIENT_SECRET") or secrets.token_hex(16),
        },
    }


class BaseHandler(web.RequestHandler):
    def get_current_user(self):
        user_id = self.get_signed_cookie("user", max_age_days=1)
        return user_id.decode() if user_id else None

    @property
    def cookie_flags(self) -> dict:
        return {"httponly": True, "samesite": "Lax", "secure": self.settings["cookie_secure"]}


class HomeHandler(BaseHandler):
    @web.authenticated
    def get(self):
        self.write({"user": self.current_user})

BaseHandler.cookie_flags shows the cookie attributes every login-related cookie should carry: httponly (scripts cannot read it), samesite="Lax" (the cookie accompanies the top-level navigation back from the provider, but not cross-site sub-requests) and secure (HTTPS only, switched on through an environment variable so that local HTTP development keeps working). set_signed_cookie() passes its extra keyword arguments to set_cookie().

Signing in with Google

A complete Google login handler is the mixin plus about thirty lines. Everything outside the three mixin calls (authorize_redirect, get_authenticated_user, oauth2_request) is the part that the mixin leaves to you, and it is the part that makes the difference between a working and a safe sign-in:

class GoogleLoginHandler(BaseHandler, GoogleOAuth2Mixin):
    async def get(self):
        redirect_uri = urljoin(self.settings["redirect_base_uri"], self.reverse_url("google_callback"))
        if self.get_argument("error", None):
            raise web.HTTPError(403, "provider refused: %s", self.get_argument("error"))
        if not self.get_argument("code", None):
            # Step 1: remember a random value and send the browser to Google.
            state = secrets.token_urlsafe(32)
            self.set_signed_cookie("oauth_state", state, expires_days=None, **self.cookie_flags)
            self.authorize_redirect(
                redirect_uri=redirect_uri,
                client_id=self.get_google_oauth_settings()["key"],
                scope=["openid", "email", "profile"],
                response_type="code",
                extra_params={"state": state},
            )
            return
        # Step 2: Google sent the browser back with ?code=...&state=...
        expected = self.get_signed_cookie("oauth_state", max_age_days=0.01)
        received = self.get_argument("state", "")
        self.clear_cookie("oauth_state")
        if expected is None or not hmac.compare_digest(expected, received.encode()):
            raise web.HTTPError(403, "state mismatch")
        tokens = await self.get_authenticated_user(redirect_uri=redirect_uri, code=self.get_argument("code"))
        profile = await self.oauth2_request(self._OAUTH_USERINFO_URL, access_token=tokens["access_token"])
        # Keep only an identifier in the cookie: a signed cookie is readable by the browser.
        self.set_signed_cookie("user", profile["id"], expires_days=1, **self.cookie_flags)
        self.redirect("/")

The points worth studying in this handler:

  • The route is registered with url(r"/login/google", GoogleLoginHandler, name="google_callback"), so the same URL serves both legs and reverse_url("google_callback") gives the path to register as the redirect URI.

  • The scope list is joined with spaces by authorize_redirect. openid email profile asks Google for the standard profile claims; the mixin does not request or parse an ID token, so the openid scope is of no use unless you read the id_token field of the token response yourself (see the last section).

  • get_authenticated_user returns the token response only. The example then calls oauth2_request against _OAUTH_USERINFO_URL, a class attribute of GoogleOAuth2Mixin that holds Google’s userinfo endpoint. It is a private-looking name, so a cautious application keeps the URL in its own configuration instead. The user’s stable identifier in that response is id; the email address is a property of the account, not an identity, and may change.

  • HTTPError(403, "provider refused: %s", ...) follows the log_message convention described in Error Handling: the text goes to the log, not to the user.

  • The user cookie contains only the provider’s user id. Google’s own example in the Tornado documentation stores the access token in the cookie as well; a signed cookie is signed, not encrypted, so the token would be readable by the user and by any script that managed to read the cookie. Store tokens server-side (a database or a cache keyed by the user id) if you need them at all, and prefer not to keep them when you only needed the identity.

Trying it against a fake provider

To run this handler without a Google account, the next block builds a local identity provider with three tiny handlers (authorize, token, userinfo) on the same loopback server, and plays the browser by following redirects manually while keeping cookies. The test points the mixin at the fake provider by overriding the three URL class attributes in a subclass, which is the same mechanism that lets you add providers that Tornado does not ship:

import asyncio
import contextlib
from http.cookies import SimpleCookie
from urllib.parse import urlencode, urlparse

from tornado import httpserver, netutil
from tornado.httpclient import AsyncHTTPClient
from tornado.web import Application, RequestHandler, url

CODES: dict[str, dict] = {}


class FakeAuthorize(RequestHandler):
    def get(self):
        code = secrets.token_hex(8)
        CODES[code] = {"challenge": self.get_argument("code_challenge", None)}
        query = {"code": code, "state": self.get_argument("state")}
        self.redirect(self.get_argument("redirect_uri") + "?" + urlencode(query))


class FakeToken(RequestHandler):
    def post(self):
        grant = CODES.pop(self.get_body_argument("code"), None)
        if not grant or self.get_body_argument("client_secret") != self.settings["idp_secret"]:
            self.set_status(400)
            self.finish({"error": "invalid_grant"})
            return
        self.write({"access_token": "at-" + secrets.token_hex(8), "token_type": "Bearer"})


class FakeUserinfo(RequestHandler):
    def get(self):
        self.write({"id": "u-1001", "name": "Ada Reader", "email": "ada@example.test"})


@contextlib.asynccontextmanager
async def serve(routes, **settings):
    sockets = netutil.bind_sockets(0, "127.0.0.1")
    base = f"http://127.0.0.1:{sockets[0].getsockname()[1]}"
    config = load_settings()
    config.update(redirect_base_uri=base, idp_secret=config["google_oauth"]["secret"])
    config.update(settings)
    server = httpserver.HTTPServer(Application(routes(base), **config))
    server.add_sockets(sockets)
    try:
        yield base
    finally:
        server.stop()


async def browse(base, path="/", jar=None):
    """Play the browser: follow redirects by hand, keep cookies, return the trail and last response."""
    jar = SimpleCookie() if jar is None else jar
    client, target, trail = AsyncHTTPClient(), base + path, []
    for _ in range(8):
        cookie = "; ".join(f"{k}={m.value}" for k, m in jar.items())
        resp = await client.fetch(target, headers={"Cookie": cookie}, follow_redirects=False, raise_error=False)
        for header in resp.headers.get_list("Set-Cookie"):
            jar.load(header)
        trail.append((resp.code, urlparse(target).path))
        if resp.code not in (301, 302, 303):
            return trail, resp
        target = urljoin(target, resp.headers["Location"])
    raise RuntimeError("redirect loop")


def google_routes(base):
    class LocalGoogleLogin(GoogleLoginHandler):  # the production handler, pointed at the fake provider
        _OAUTH_AUTHORIZE_URL = base + "/idp/authorize"
        _OAUTH_ACCESS_TOKEN_URL = base + "/idp/token"
        _OAUTH_USERINFO_URL = base + "/idp/userinfo"

    return [
        url(r"/login/google", LocalGoogleLogin, name="google_callback"),
        (r"/", HomeHandler),
        (r"/idp/authorize", FakeAuthorize),
        (r"/idp/token", FakeToken),
        (r"/idp/userinfo", FakeUserinfo),
    ]


async def google_demo():
    async with serve(google_routes) as base:
        trail, resp = await browse(base)
        print(trail)
        print(resp.body.decode())

        # A callback that carries a state the browser never stored is refused.
        resp = await AsyncHTTPClient().fetch(
            base + "/login/google?code=abc&state=forged", raise_error=False, follow_redirects=False
        )
        print(resp.code)


asyncio.run(google_demo())

The output of the first print is the redirect trail the browser walks: anonymous / is redirected to the login_url, the login handler redirects to the provider, the provider redirects back to the login handler, and the login handler finally redirects to /, which now answers 200 with the signed-in user:

[(302, '/'), (302, '/login/google'), (302, '/idp/authorize'), (302, '/login/google'), (200, '/')]
{"user": "u-1001"}
403

The first redirect (/ to /login/google) is the work of @authenticated and the login_url setting. The forged callback is rejected with 403 because no oauth_state cookie accompanies it.

State, PKCE and the Other Things the Mixins Do Not Do

tornado.auth is a convenience for the two HTTP requests of the code flow. Compare it with what the OAuth security best practices expect from a client and you find that the safeguards are all left to the application:

Protection In Tornado

state (login CSRF)

Not generated or checked. Create it yourself, send it with extra_params={"state": ...} and compare on return, as the handler above does. Without it an attacker can make a victim’s browser complete the attacker’s login. Keep the value in a signed cookie (as above) or in server-side storage, and compare with hmac.compare_digest.

PKCE (code_challenge, code_verifier)

Not supported by get_authenticated_user, whose parameters are fixed. Send the challenge through extra_params and do the token request yourself (the example in the next section).

nonce and ID token validation

Not supported. Tornado never parses the id_token that OpenID Connect providers return. Validating its signature, iss, aud, exp and nonce is up to you (PyJWT, or a full client library).

Token storage, refresh and revocation

Not supported. The mixin returns the token response; refreshing and revoking are further token-endpoint calls that you write.

Access token transport

oauth2_request appends the token to the URL as a query parameter, so it can end up in access logs of the provider and of any proxy. Providers that accept an Authorization: Bearer header are better called with AsyncHTTPClient directly.

Open redirects after login

If your login handler honours a next parameter, restrict it to relative paths of your own site before calling redirect().

Cross-site request forgery on the application

A separate matter, handled by xsrf_cookies; see XSRF & Cross-Origin Protection.

A few further pitfalls:

  • Do not build redirect_uri from the request. Behind a proxy the scheme and host are not what the provider registered, and a header you do not control must not decide where a code is sent.

  • Authentication is not authorisation. A valid Google login proves an identity, not that this person may read the Bookshelf Live admin pages; map the provider’s stable user id (id, or sub for OpenID Connect) to your own user record and roles. Accept an email address as a login key only when the provider states it is verified, and never merge accounts only because two providers report the same address.

  • Use HTTPS and secure cookies in production, and send the Cache-Control: no-store header on responses that carry tokens.

  • Handle failures from the provider. get_authenticated_user raises the HTTPClientError that AsyncHTTPClient.fetch raises when the token endpoint answers 4xx or 5xx (for example invalid_grant for a reused code). Turn it into a 401 or a retry page instead of letting it become an anonymous 500.

Adding Another OAuth 2.0 Provider

OAuth2Mixin is abstract: it expects the subclass to define _OAUTH_AUTHORIZE_URL and _OAUTH_ACCESS_TOKEN_URL, and it leaves the token request to you. That is exactly the right place to add PKCE. The next example defines a BookClub ID provider (a made-up service that requires PKCE) as a mixin of its own and does the code exchange with the HTTP client returned by get_auth_http_client(), so tests can replace it:

import base64
import hashlib

from tornado import escape
from tornado.auth import OAuth2Mixin


class BookClubMixin(OAuth2Mixin):
    _OAUTH_AUTHORIZE_URL = "https://id.bookclub.example/oauth/authorize"
    _OAUTH_ACCESS_TOKEN_URL = "https://id.bookclub.example/oauth/token"

    async def exchange_code(self, *, redirect_uri, code, code_verifier, client_id, client_secret):
        body = urlencode({
            "grant_type": "authorization_code",
            "redirect_uri": redirect_uri,
            "code": code,
            "code_verifier": code_verifier,
            "client_id": client_id,
            "client_secret": client_secret,
        })
        response = await self.get_auth_http_client().fetch(
            self._OAUTH_ACCESS_TOKEN_URL,
            method="POST",
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            body=body,
        )
        return escape.json_decode(response.body)


def s256(verifier: str) -> str:
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")


class BookClubLoginHandler(BaseHandler, BookClubMixin):
    async def get(self):
        redirect_uri = urljoin(self.settings["redirect_base_uri"], self.reverse_url("bookclub_callback"))
        creds = self.settings["bookclub_oauth"]
        if not self.get_argument("code", None):
            state, verifier = secrets.token_urlsafe(32), secrets.token_urlsafe(64)
            self.set_signed_cookie("oauth_state", f"{state}.{verifier}", expires_days=None, **self.cookie_flags)
            self.authorize_redirect(
                redirect_uri=redirect_uri,
                client_id=creds["key"],
                scope=["profile"],
                extra_params={"state": state, "code_challenge": s256(verifier), "code_challenge_method": "S256"},
            )
            return
        stored = (self.get_signed_cookie("oauth_state", max_age_days=0.01) or b"").decode()
        self.clear_cookie("oauth_state")
        state, _, verifier = stored.partition(".")
        if not state or not hmac.compare_digest(state, self.get_argument("state", "")):
            raise web.HTTPError(403, "state mismatch")
        tokens = await self.exchange_code(
            redirect_uri=redirect_uri, code=self.get_argument("code"), code_verifier=verifier,
            client_id=creds["key"], client_secret=creds["secret"],
        )
        self.set_signed_cookie("user", "bookclub:ada", expires_days=1, **self.cookie_flags)
        self.redirect("/")

The verifier never leaves the server except in the token request, and the challenge is its SHA-256 digest (the S256 method of RFC 7636). The state and the verifier are stored together in one signed cookie, since both must come back with the same browser. The user value stored at the end is deliberately a placeholder: a real application would call the provider’s userinfo endpoint and store the provider’s own user id.

To run it, the fake provider must now verify the challenge. The test swaps in a stricter token endpoint and points the mixin at the loopback server:

class StrictFakeToken(FakeToken):
    def post(self):
        grant = CODES.get(self.get_body_argument("code"))
        verifier = self.get_body_argument("code_verifier", "")
        if grant and grant["challenge"] != s256(verifier):
            CODES.pop(self.get_body_argument("code"))
            self.set_status(400)
            self.finish({"error": "invalid_grant", "error_description": "PKCE verification failed"})
            return
        super().post()


def bookclub_routes(base):
    class LocalBookClubLogin(BookClubLoginHandler):
        _OAUTH_AUTHORIZE_URL = base + "/idp/authorize"
        _OAUTH_ACCESS_TOKEN_URL = base + "/idp/token"

    return [
        url(r"/login/bookclub", LocalBookClubLogin, name="bookclub_callback"),
        (r"/", HomeHandler),
        (r"/idp/authorize", FakeAuthorize),
        (r"/idp/token", StrictFakeToken),
    ]


async def bookclub_demo():
    secret = secrets.token_hex(16)
    async with serve(bookclub_routes, login_url="/login/bookclub",
                     bookclub_oauth={"key": "bookshelf-live", "secret": secret}, idp_secret=secret) as base:
        trail, resp = await browse(base)
        print(trail)
        print(resp.body.decode())


asyncio.run(bookclub_demo())
[(302, '/'), (302, '/login/bookclub'), (302, '/idp/authorize'), (302, '/login/bookclub'), (200, '/')]
{"user": "bookclub:ada"}

Facebook and the Graph API

FacebookGraphMixin follows the same shape with two differences. get_authenticated_user(redirect_uri, client_id, client_secret, code, extra_fields=None) takes the client credentials as arguments (there is no settings key for them, the documentation example reads facebook_api_key and facebook_secret from the application settings) and returns a user dictionary rather than a token response: it exchanges the code, then calls the Graph API path /me with an appsecret_proof (an HMAC-SHA256 of the access token, keyed by the client secret) and copies the fields id, name, first_name, last_name, locale, picture and link, plus any named in extra_fields, together with access_token and session_expires (a string). facebook_request(path, access_token=None, post_args=None, **args) is a thin wrapper over oauth2_request that prepends _FACEBOOK_BASE_URL.

class FacebookLoginHandler(BaseHandler, FacebookGraphMixin):
    async def get(self):
        redirect_uri = urljoin(self.settings["redirect_base_uri"], self.reverse_url("facebook_callback"))
        creds = self.settings["facebook_oauth"]
        if not self.get_argument("code", None):
            state = secrets.token_urlsafe(32)
            self.set_signed_cookie("oauth_state", state, expires_days=None, **self.cookie_flags)
            self.authorize_redirect(
                redirect_uri=redirect_uri,
                client_id=creds["key"],
                extra_params={"scope": "public_profile", "state": state},
            )
            return
        expected = self.get_signed_cookie("oauth_state", max_age_days=0.01)
        self.clear_cookie("oauth_state")
        if expected is None or not hmac.compare_digest(expected, self.get_argument("state", "").encode()):
            raise web.HTTPError(403, "state mismatch")
        user = await self.get_authenticated_user(
            redirect_uri=redirect_uri, client_id=creds["key"], client_secret=creds["secret"],
            code=self.get_argument("code"),
        )
        if user is None:
            raise web.HTTPError(401, "the provider returned no profile")
        self.set_signed_cookie("user", "facebook:" + user["id"], expires_days=1, **self.cookie_flags)
        self.redirect("/")

Run against a fake Graph API, the handler gives the same redirect trail as the others. The fake exposes the token endpoint (a GET, because that is how this mixin calls it) and /me:

class FakeFacebookToken(RequestHandler):
    def get(self):
        if not CODES.pop(self.get_argument("code"), None) or self.get_argument("client_secret") != self.settings["idp_secret"]:
            self.set_status(400)
            self.finish({"error": {"message": "invalid code"}})
            return
        self.write({"access_token": "at-" + secrets.token_hex(8), "expires_in": 3600})


class FakeGraphMe(RequestHandler):
    def get(self):
        assert self.get_argument("appsecret_proof")      # sent by the mixin
        self.write({"id": "fb-77", "name": "Ada Reader"})


def facebook_routes(base):
    class LocalFacebookLogin(FacebookLoginHandler):
        _OAUTH_AUTHORIZE_URL = base + "/idp/authorize?"
        _OAUTH_ACCESS_TOKEN_URL = base + "/graph/token?"
        _FACEBOOK_BASE_URL = base + "/graph"

    return [
        url(r"/login/facebook", LocalFacebookLogin, name="facebook_callback"),
        (r"/", HomeHandler),
        (r"/idp/authorize", FakeAuthorize),
        (r"/graph/token", FakeFacebookToken),
        (r"/graph/me", FakeGraphMe),
    ]


async def facebook_demo():
    secret = secrets.token_hex(16)
    async with serve(facebook_routes, login_url="/login/facebook",
                     facebook_oauth={"key": "bookshelf-live", "secret": secret}, idp_secret=secret) as base:
        trail, resp = await browse(base)
        print(trail, resp.body.decode())


asyncio.run(facebook_demo())
[(302, '/'), (302, '/login/facebook'), (302, '/idp/authorize'), (302, '/login/facebook'), (200, '/')] {"user": "facebook:fb-77"}

Two cautions apply before relying on this mixin in production. The client secret travels as a query parameter of the token request, because that is how the mixin calls the endpoint, and the Graph API paths in the module carry no API version; Meta changes permissions, review requirements and field availability regularly, so check the current Meta documentation for the scopes and fields you request and test against a real app in development mode. If Meta’s requirements are not met by the mixin, the same code-for-token exchange written with AsyncHTTPClient (as in the BookClub example) gives you full control.

Deprecated Mixins: OpenID 2.0, OAuth 1.0a and Twitter

Three parts of the module are legacy and must not be used in new code.

  • OpenIdMixin (methods authenticate_redirect and get_authenticated_user) implements OpenID 2.0 with Attribute Exchange. The documentation marks it deprecated since 6.6 and scheduled for removal in 6.7, and the 6.5.10 code already warns when a handler using it is instantiated, as shown below. The migration path is OpenID Connect, a different protocol built on OAuth 2.0.

  • OAuthMixin is the abstract OAuth 1.0 and 1.0a client (authorize_redirect, get_authenticated_user, the _OAUTH_REQUEST_TOKEN_URL, _OAUTH_ACCESS_TOKEN_URL, _OAUTH_AUTHORIZE_URL and _OAUTH_VERSION attributes). Its only user inside Tornado was Twitter. Nearly every provider has moved to OAuth 2.0; see OAuth 1.0 for the history of the protocol.

  • TwitterMixin (authenticate_redirect, authorize_redirect, twitter_request) targets version 1.1 of the Twitter API and the twitter_consumer_key and twitter_consumer_secret settings. It has been deprecated since 6.3 because that API has been retired; the documentation says it will not be updated and will be removed. It is not shown in an example because it can no longer work against the service.

import warnings

from tornado.auth import OpenIdMixin


class LegacyOpenIdHandler(web.RequestHandler, OpenIdMixin):
    def get(self):
        self.write("legacy")


async def openid_demo():
    async with serve(lambda base: [(r"/legacy", LegacyOpenIdHandler)]) as base:
        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            await AsyncHTTPClient().fetch(base + "/legacy")   # the server runs in this process
        print([str(w.message) for w in caught])


asyncio.run(openid_demo())
['OpenIdMixin is deprecated and will be removed in Tornado 6.7']

Run your test suite with python -W error::DeprecationWarning (or the equivalent pytest setting) so that code that still touches these classes fails early. Do not confuse the legacy OpenIdMixin with OpenID Connect; if your application previously used it to sign in with Google through "OpenID", the provider stopped supporting that protocol years ago and the replacement is GoogleOAuth2Mixin or a full OpenID Connect client.

When to Use an OpenID Connect Library Instead

The mixins are a good fit when you need "sign in with Google" for an application you control, with the user identified by the userinfo response. Reach for a dedicated library when you need any of the following, which tornado.auth does not do:

Authlib is a widely used library of this kind; it can be used with its own async HTTP client (HTTPX) inside a Tornado handler, because Tornado handlers are coroutines on the asyncio loop, and a framework-agnostic OpenID Connect client or an identity provider’s SDK can be used the same way. Whichever you choose, the three integration points with Tornado stay the same: redirect with self.redirect(), keep the state in a signed cookie or server-side store, and keep the user’s identifier in a signed cookie read by get_current_user.

Testing Login Flows

Everything above ran without a real provider, and the technique carries over to a test suite:

  • Point the mixin at a local fake by overriding _OAUTH_AUTHORIZE_URL, _OAUTH_ACCESS_TOKEN_URL and, for the Google and Facebook mixins, _OAUTH_USERINFO_URL or _FACEBOOK_BASE_URL on a subclass, as in the examples. These are the extension points the module documents for new providers.

  • Or override get_auth_http_client() to return a stub whose fetch returns canned HTTPResponse objects; this tests the handler without any sockets.

  • With tornado.testing.AsyncHTTPTestCase, fetch with follow_redirects=False and assert on Location and Set-Cookie, the way the browse helper did.

  • Test the negative cases: missing state, forged state, replayed code, provider error, a provider outage. Use ExpectLog (see Error Handling) to silence the expected log lines.

  • Sign users in directly in other tests by creating the signed cookie with handler.create_signed_value("user", "u-1001"), instead of replaying the whole flow in every test.

Recent Changes

Version Change

6.0

The callback arguments of the mixins were removed; get_authenticated_user and the request methods are coroutines, and authorize_redirect is an ordinary synchronous method.

6.3

TwitterMixin deprecated.

6.4

The client_secret argument of OAuth2Mixin.authorize_redirect deprecated (removal in 7.0).

6.5.x

No change to the public interface of tornado.auth.

6.6 (upcoming, only 6.6a1 published)

OpenIdMixin documented as deprecated, with removal in 6.7. Python 3.11 becomes the minimum version.

How This Compares

Aspect Tornado tornado.auth Flask or FastAPI with Authlib

Scope

Two HTTP requests of the code flow, per provider.

Client registry, discovery, ID token validation, refresh, many providers.

state, PKCE, nonce

Application code.

Handled by the library.

Session

Signed cookie (set_signed_cookie), your code.

Framework session or token store.

Async

Native: mixins are coroutines.

Async variants exist for the HTTPX client.

For the Flask version of the same task see Flask: OAuth, Social Login & LDAP; for how Django handles social login see Django.

Summary

  • tornado.auth provides mixins: GoogleOAuth2Mixin, FacebookGraphMixin and the generic OAuth2Mixin are the current ones; OpenIdMixin, OAuthMixin and TwitterMixin are legacy.

  • The flow is authorize_redirect(), the provider, a callback with code, then get_authenticated_user() and oauth2_request(); one handler usually serves both legs.

  • The mixins do not generate or check state, do not support PKCE, nonces or ID token validation, and put the token in a query string; add those yourself or use an OpenID Connect library.

  • Keep credentials in the environment, the user’s identifier (not the tokens) in a signed cookie, and test the whole flow against a local fake provider.