Starlette and Two Factor Authorization / TOTP

Written by Walter on 14/02/2021

< >

Login screen

Last weekend I saw this nice and cool flask video about 2FA/Fido (TOTP) for Flask by Michael Grinberg. Decided to make the same work for my Starlette website.

I did have some extra work on my hands because Starlette does not have sessions and the way databases work is also different. But I did manage to get it working.

As you can already see on this site. If you click on the top right profile button. You are presented with a login screen (gone is the simple basic auth popup from my previous blogpost).

And yes it's fully functional. I do want to mention there's an awesome browser plugin called Authenticator. With that you can use OTP on your desktop without typing. And you can password lock/encrypt your otp secrets with it. And yes for the master password the AnyKey is yet again an ideal addition for added security.

So after typing in my username and password. I also need to add a valid 2FA token. This one I can either copy with my phone or with the Authenticator plugin it's even easier because you can insert it without having to type it yourself.

Authenticator plugin

For the registration fase I kept everything the same as Migael's example for now. But I will move the whole two factor setup part into the profile pages instead and make it optional.

The cool thing is we've got user roles as well. All new registered users are normally just 'Guest' but for this login I changed the role in the database to admin and this allows me to add some extra buttons and features for admin users. For instance only admin's get to see and use this 'Create blog item' button. And when clicking it ofcourse your authorization token in the form of a http cookie with jwt signature is checked so its pretty secure and efficient as well.

The meat of the authorization implementation for Starlette is with some middleware code. And using cookies that contain a signed jwt token. By having the token signed it gives us better performance on the backend because we don't need to do an extra request to fetch a user from the database. If the jwt is valid we can assume the role inside the json of the cookie is also legit.

The authorization middleware for Starlette, allowing proper login, logout and registration:

class JwtAuthBackend(AuthenticationBackend):
    async def authenticate(self, request):
        cookie_authorization = request.cookies.get(COOKIE_NAME)

        if cookie_authorization:
            scheme, token = cookie_authorization.split()
            if scheme.lower() != 'bearer':

                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
                username = payload.get("sub")
                user_role = payload.get("role")
                user_id = payload.get("user_id")

                if username is None:
                    raise AuthenticationError('Invalid credentials')

            except PyJWTError:
                return # invalid signature, not logged in

            # classical way: lookup a user everytime in the db with user_id or some field like username
            # But our JWT is signed, so we can skip this extra query here!
            # user = await get_user(database, username)
            # if user is None:
            #    raise AuthenticationError('User not found')

            # instead, in our JWT signature we trust!
            blog_user = BlogUser(username, user_id, user_role)

            # login success, return blog_user object with correct role
            return AuthCredentials(blog_user.roles), blog_user

            return  # not logged in

The BlogUser is like Starlette's SimpleUser class but it adds roles. So users can have different credentials based on a database role or flag. In my case I've got guests, admins and moderators. But you could easily extend this to something more elaborate with user groups or teams etc.

class BlogUser(BaseUser):
    def __init__(self, username: str, user_id=None, role=None) -> None:
        self.username = username
        self.roles = ['authenticated']
        self.user_id = user_id

        if role:

    def is_admin(self) -> bool:
        return 'admin' in self.roles

    def is_moderator(self) -> bool:
        return 'moderator' in self.roles

    def is_authenticated(self) -> bool:
        return True

    def display_name(self) -> str:
        return self.username

Checking the 2FA token is also super easy thanks to the onetimepass python package. Also this is the only time we query the database. We do it once when you login. Then we create a signed jwt token with the create_access_token call and after that we never need to query the database again in this setup. This is really nice because it allows for performant pages that don't need to hit the database in order to check the user permissions.

def verify_totp(user, token):
    return onetimepass.valid_totp(token, user['otp_secret'])

async def otp_login(response, username, password, token):
    user = await authenticate_user(database, username, password)
    if not user or not verify_totp(user, token) :
        return login_error('Invalid username, password or token')

    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
            "sub": user['username'],
            "role": user['role'],
            "user_id": user['id'],

    token = str(access_token)

        value=f"Bearer {token}",
        domain=COOKIE_DOMAIN, # only for specific domain
        secure=True,   # only allow on https
        httponly=True, # only for browser, no js access
    return response

Ow and yes the final piece of the puzzle is to inject these middlewares to make it all work:

from lib.authorization import (
    JwtAuthBackend, otp_login, logout,
    get_totp_uri, get_totp_secret,

from starlette.authentication import requires
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware

middleware = [
    Middleware(AuthenticationMiddleware, backend=JwtAuthBackend())

app = Starlette(middleware=middleware)

Protecting backend endpoints becomes super easy also with the Starlette decorators (which ressemble rails before_action action filters in functionality). Checking for any logged in user :

@app.route("/profile", methods=['GET'])
async def user_profile(request):
  # ... your code here
  return response

Checking for a user with an admin role:

@app.route("/admin_page", methods=['GET'])
@requires(["authenticated", "admin"])
async def some_administration_route(request):
  # ... your code here
  return response

By default, if the authenticated decorator fails we get back a 403 unauthorized response. And we use this catch all handler to show a nicer access denied like so:

def access_denied(request, exc):
    Return an HTTP 403 access denied page.
    template = "403.html"
    context = {"request": request}
    return templates.TemplateResponse(template, context, status_code=403)

So there we have it. To be honest it's quite a lot of boilerplate code to just get a login/logout working. Other frameworks like Rails, Amber or even Flask do provide a lot more out of the box libraries and packages to help with this basic authorization and login+registration flows and most of them provide session cookie stores as well.

But doing it all from scratch, does allow you to fully understand what is going on. And in this case it's really efficient as well. I've worked on many projects where an extra query or multiple database queries were mandatory to securely validate a user's credentials and access roles. This implementation does it with basically zero queries on the routes and only 1 for the login call. The same techniques can be applied in other frameworks in the future for myself. So I see this mostly as a nice excercise to have all the pieces for an authentiction system done with minimal amount of maintenance and maximum performance. Once the setup is complete adding new protected routes is a breeze with the decorators.

There are other/similar middlewares available on git like apistar and starlette-authlib but altering them to my specific requirements would have been an equal amount of work.

Back to archive