Adding a login system to your web app can seem daunting, but it doesn’t have to be. Rather than reinventing the wheel with usernames, passwords, and complicated authentication flows, modern best practices often lean on OAuth, an open standard that lets users sign in with services they already trust, like Google, GitHub, and Facebook.
In this tutorial, we’ll walk through the process of adding a Google OAuth login system to a FastHTML app. It’s a similar process to add GitHub or other social logins. FastHTML is a minimal but powerful Python web framework, and its built-in support for OAuth makes it pretty easy to set up a secure, passwordless sign-in. We’ll also connect this to a local SQLite database to store user information.
This could then be expanded to include integration with a payment system such as Stripe, or Polar. I might cover how to do this in a future blog post.
FastHTML OAuth Docs and Set Up
I’ll be following the FastHTML OAuth documentation for this tutorial. I’d recommend giving this a read before continuing to familiarise yourself with the process.
First up let’s create an initial FastHTML app we can iterate on. I use uv to scaffold and run most of my FastHTML apps these days. I even created my own simple FastHTML CLI to make this even easier. You don’t have to use this but it’s a useful utility. Make sure uv is installed if you wish to use it. You can also just use pip install if you’re more familiar with that.
In the terminal navigate to where you want your FastHTML app folder to be created and enter:
uvx fh-init google-oauth -tThe -t flag here swaps out the default Pico CSS styles to use Tailwind CSS instead.
$: uvx fh-init google-oauth -t
✨ New FastHTML app created successfully!
To get started, enter:
$ cd google-oauth
$ uv run main.pyThis creates two files:
main.py
from fasthtml.common import *
hdrs = (Script(src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"),)
app,rt = fast_app(hdrs=hdrs, pico=False)
@rt('/')
def get(): return Div(P("Hello, world!!", cls="m-6"))
serve()pyproject.toml
[project]
name = "google-oauth"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = ["python-fasthtml"]The toml file specifies Python 3.12 or higher be used and with a single dependency which is the FastHTML package. You can create these files manually if you don’t want to use the fh-init CLI. cd into the app folder and run:
uv run main.pyThis will install the necessary dependencies specified in pyproject.toml the first time you run it, and then start the FastHTML server:
$: uv run main.py
Using CPython 3.12.10
Creating virtual environment at: .venv
Installed 30 packages in 31ms
Link: http://localhost:5001
INFO: Will watch for changes in these directories: ['/home/dgwyer/fasthtml/google-oauth']
INFO: Uvicorn running on http://0.0.0.0:5001 (Press CTRL+C to quit)
INFO: Started reloader process [24321] using WatchFiles
INFO: Started server process [24332]
INFO: Waiting for application startup.
INFO: Application startup complete.Open up the app in your browser to see it running at: http://localhost:5001/

Register a Google OAuth Application
In order to offer login via Google you’ll need to register an application in Google Cloud Console to get the client ID and secret. This is fairly straightforward but might seem daunting the first time you do it. Fortunately there are plenty of helpful videos such as this one to walk you through the process. Google loves to change the user interface though from time-to-time so you might see something very different when you login to Google Console. If this happens just try to search for a more recent video which explains the process.
Once you have created the Google OAuth app and have the client ID and client secret, create a .env file and add the details. Something like this (dummy example):
GOOGLE_AUTH_CLIENT_ID=69183395333186-b3pghs65nkshfd1der092deejf74f83hfv4.apps.googleusercontent.com
GOOGLE_AUTH_CLIENT_SECRET=ZRBPWF-By6jhCe$VF6893voejboeihtofggHHe44
To make sure the OAuth redirects work properly you need to add /redirect to the authorised redirect URI in the Google console (the default redirect path used in the FastHTML GoogleAppClient OAuth provider covered in the next section). Your Google Console page should look something like this:

Also, don’t forgt to add the .env file to .gitignore if you plan on pushng to a repository as you don’t want confidential details being publicly visible.
FastHTML OAuth
There’s a lot of complexity to the OAuth open standard but FastHTML helps us out immediately by abstracting a lot of the boilerplate code away behind a nice usable class interface for the various OAuth providers. Currently FastHTML supports the following providers:
- GoogleAppClient
- GitHubAppClient
- HuggingFaceClient
- DiscordAppClient
As we’ll be using Google OAuth let’s import GoogleAppClient.
from fasthtml.oauth import GoogleAppClientWe’ll also need to somehow include the Google client ID and client secret which we just obtained from our Google account. There are a couple of ways we can do this but I often use the Python python-dotenv package. Add python-dotenv to the pyproject.toml dependencies list, import it into main/py and call the loaddotenv() function to make the Google OAuth client and secret details safely available in our FastHTML app environment:
from fasthtml.common import *
from fasthtml.oauth import GoogleAppClient
from dotenv import load_dotenv
import os
load_dotenv()
client = GoogleAppClient(os.getenv("GOOGLE_AUTH_CLIENT_ID"),
os.getenv("GOOGLE_AUTH_CLIENT_SECRET"))
hdrs = (Script(src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"),)
app,rt = fast_app(hdrs=hdrs, pico=False)
@rt('/')
def get(): return Div(P("Hello, world!!", cls="m-6"))
serve()Once we call GoogleAppClient() we have access to the Google OAuth client which includes the remote Google login link, and manage back-and-forth communications between your FastHTML app and the OAuth provider. From this we can create a minimal working login system:
from fasthtml.common import *
from fasthtml.oauth import GoogleAppClient, OAuth
from dotenv import load_dotenv
import os
load_dotenv()
client = GoogleAppClient(os.getenv("GOOGLE_AUTH_CLIENT_ID"),
os.getenv("GOOGLE_AUTH_CLIENT_SECRET"))
class Auth(OAuth):
def get_auth(self, info, ident, session, state):
email = info.email or ''
if info.email_verified:
return RedirectResponse('/', status_code=303)
hdrs = (Script(src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"),)
app,rt = fast_app(hdrs=hdrs, pico=False)
oauth = Auth(app, client)
@rt('/')
def home(auth): return P('Logged in!'), A('Log out', href='/logout')
@rt('/login')
def login(req): return Div(P("Not logged in"), A('Log in', href=oauth.login_link(req)))
serve()FastHTML makes adding OAuth authentication straightforward by providing a core OAuth class that handles most of the heavy lifting for login, logout, and redirects. After creating a client for your OAuth provider (e.g., Google in our case), we subclass OAuth and override get_auth to define our login rules, add new users to the database (we’ll cover this shortly), and handle the login redirect. For now we just redirect to the home page. When the Auth class is initialized with Auth(app, client), FastHTML automatically sets up redirect and logout routes, along with beforeware that checks authentication on incoming requests and redirects unauthenticated users to a /login page. You control the /login, /logout, and /error routes, while FastHTML manages the redirect flow and session handling.
The login process begins when a user clicks the login link, which sends them to the (Google) OAuth provider to grant permissions. After approval, the provider redirects them back to /redirect with a code that FastHTML exchanges for user information, passing it along to your overidden get_auth method. If authentication is successful an auth key is stored in the session so the user stays logged in across visits until they log out via /logout. This setup makes it easy to integrate secure, session-based OAuth authentication into a FastHTML app without building the entire flow from scratch.
For a more detailed breakdown of this see the official docs on the OAuth class or the core FastHTML OAuth code.
This doesn’t look too exciting yet but login and logout works!


Improving the UI
Let’s make the UI a little more aesthetic but keep it as simple as possible. We’ll add a custom FastHTML component that renders a nav menu with some links, and a login/logout link.
from fasthtml.common import *
from fasthtml.oauth import GoogleAppClient, OAuth
from dotenv import load_dotenv
import os
load_dotenv()
client = GoogleAppClient(os.getenv("GOOGLE_AUTH_CLIENT_ID"),
os.getenv("GOOGLE_AUTH_CLIENT_SECRET"))
class Auth(OAuth):
def get_auth(self, info, ident, session, state):
print(f"info: {info}")
print(f"ident: {ident}")
print(f"session: {session}")
print(f"state: {state}")
email = info.email or ''
if info.email_verified:
return RedirectResponse('/', status_code=303)
hdrs = (Script(src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"),)
app,rt = fast_app(hdrs=hdrs, pico=False)
oauth = Auth(app, client)
def NavComponent(auth_link):
return Nav(
Div(
A('Home', href='#', cls='font-semibold tracking-tight hover:text-blue-600'),
Div(
A('Features', href='/features', cls='hover:text-blue-600'),
A('About', href='/about', cls='hover:text-blue-600'),
A('Generate Image', href='/generate', cls='hover:text-blue-600'),
cls='flex flex-col sm:flex-row gap-4 sm:gap-6'
),
auth_link,
cls='flex flex-col sm:flex-row sm:items-center sm:justify-between py-4 gap-4'
),
cls='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'
)
@rt('/')
def home(auth): return Div(
NavComponent(A('Log out', href='/logout', cls='hover:text-blue-600')),
Div(
P(f'Logged in!'),
cls='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'
)
)
@rt('/login')
def login(req): return Div(
NavComponent(A('Log in', href=oauth.login_link(req), cls='hover:text-blue-600')),
Div(
P("Not logged in"),
cls='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'
)
)
serve()Don’t worry about the extra markup, or pay too close attention to the Tailwind CSS classes for now. The main logic of authentication hasn’t changed at all. We’ll hook up the extra links (FastHTML routes) next.


Custom Login Redirect
Things are progressing nicely now, but we need to tweak login and logout behavior. Right now no matter what route we login from we get redirected to the homepage after login. It would be nice to stay on the same page after login. Also, if we login from the /login route (this route is triggered when we access a protected page) we get redirected the login route again which doesn’t make sense. We’d prefer to be redirected to the homepage in this case. And finally for now, after logout we should always be redirected to homepage, rather than login route.
See the code below for the changes:
from fasthtml.common import *
from fasthtml.oauth import GoogleAppClient, OAuth
from dotenv import load_dotenv
import os
load_dotenv()
client = GoogleAppClient(os.getenv("GOOGLE_AUTH_CLIENT_ID"),
os.getenv("GOOGLE_AUTH_CLIENT_SECRET"))
class Auth(OAuth):
def get_auth(self, info, ident, session, state):
if info.email_verified:
# Store the access token in session for later revocation
if hasattr(self, 'cli') and hasattr(self.cli, 'token'):
session['access_token'] = self.cli.token.get('access_token')
# Check if there's a stored intended destination from when user was redirected to login
intended_url = session.get('intended_url')
if intended_url and intended_url != '/login':
session.pop('intended_url', None) # Remove after using
return RedirectResponse(intended_url, status_code=303)
# If user logged in from /login route, redirect to homepage
if state and state.endswith('/login'):
redirect_url = '/'
else:
redirect_url = state if state else '/'
return RedirectResponse(redirect_url, status_code=303)
def logout(self, session):
# Redirect to homepage after logout
return RedirectResponse('/', status_code=303)
hdrs = (Script(src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"),)
app,rt = fast_app(hdrs=hdrs, pico=False)
oauth = Auth(app, client, skip=["/", "/login", "/redirect", "/features", "/about"])
def NavComponent(req=None, sess=None):
if sess and sess.get('auth'):
auth_link = A('Log out', href='/logout', cls='hover:text-blue-600')
else:
# Include current URL in state parameter for post-login redirect
current_url = str(req.url) if req else '/'
auth_link = A('Log in', href=oauth.login_link(req, state=current_url), cls='hover:text-blue-600')
return Nav(
Div(
A('Home', href='/', cls='font-semibold tracking-tight hover:text-blue-600'),
Div(
A('Features', href='/features', cls='hover:text-blue-600'),
A('About', href='/about', cls='hover:text-blue-600'),
A('Generate Image', href='/generate', cls='hover:text-blue-600'),
cls='flex flex-col sm:flex-row gap-4 sm:gap-6'
),
auth_link,
cls='flex flex-col sm:flex-row sm:items-center sm:justify-between py-4 gap-4'
),
cls='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'
)
@rt('/')
def home(req, sess): return Div(
NavComponent(req, sess),
Div(
P(f'Home page'),
cls='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'
)
)
@rt('/login')
def login(req, sess):
# If user manually visited /login (no intended_url), clear any stored URL
# so they get redirected to homepage after login
if not sess.get('intended_url'):
sess.pop('intended_url', None)
return Div(
NavComponent(req, sess),
Div(
P("Not logged in"),
cls='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'
)
)
@rt('/features')
def features(req, sess): return Div(
NavComponent(req, sess),
Div(
P("Features"),
cls='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'
)
)
@rt('/about')
def about(req, sess): return Div(
NavComponent(req, sess),
Div(
P("About"),
cls='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'
)
)
@rt('/generate')
def generate(req, sess): return Div(
NavComponent(req, sess),
Div(
P("Generate image"),
cls='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'
)
)
serve()We have updated the get_auth function to preserve the current URL after login. A special case is if we logged in from the /login route. Here we just redirect to the homepage as a fallback. A logout function has been added to override the default behavior and redirect to the homepage after logging out, rather than always redirect to the login route.
All these tweaks are an attempt to improve the UX, but you can choose your own behaviour for what happens after logging in or when logging out. The FastHTML OAuth integration is flexible enough for you to add any functionality you like!
Revoking Token Access
Next, we’ll look at implementing a system for revoking the Google token. When logging into the web app you may have noticed that you only see the Google acceptance page once (shown in the screenshot below). Every subsequent time you login, you get logged in immediately as the Google screen is skipped.

This provides a nicer UX when users log out of your FastHTML app. The session is cleared locally, but they remain authenticated with the OAuth provider (Google, GitHub, etc.). So when they login again they’re immediately redirected back without seeing the provider’s login screen; the provider remembers they already granted permission.
However, when developing your app you may wish to ‘force’ users to login fully to test the systetm is working properly. Most OAuth providers like Google offer token revocation endpoints that let you programmatically revoke access tokens:
auth_revoke_url = "https://accounts.google.com/o/oauth2/revoke"
def revoke_token(token):
response = requests.post(auth_revoke_url, params={"token": token})
return response.status_code == 200 # True if successfulImplementation details: - You need to store the access token during login - Call the revoke function during logout if you want full disconnection - Not all providers support revocation - FastHTML doesn’t include this functionality by default
OAuth provider token revocation is also useful when implementing account deletion to tidy things up.
Here’s how we can integrate it. Add the snippet of code above to your app, and also add a new /revoke route to handle logging out AND revoking Google tokan access.
@rt('/revoke')
def revoke(req, sess):
# Revoke the access token and clear session
access_token = sess.get('access_token')
if access_token:
revoke_success = revoke_token(access_token)
print(f"Token revocation {'successful' if revoke_success else 'failed'}")
# Clear the session
sess.pop('auth', None)
sess.pop('access_token', None)
# Redirect to homepage
return RedirectResponse('/', status_code=303)Now when logged in you’ll see a new ‘Revoke’ link which not only logs you out of the app but revokes the token in the OAuth provide so you’ll have to go through the full authentication process the next time you login.

Adding Database Support
To tie everything togehter we’ll add an SQLite database to create and store user details such as name, email, and Google avatar once login is complete. Subsequent logins won’t create a new database record unless the email cannot be found. In the future you could extend this to include app specific data relevant to each user. e.g. The number of image generation credits a particular user has left.
db = database('data/app.db')
class User: id: int; email:str; name:str; created_at:str; last_login:str; avatar_data:Optional[bytes] = None
users = db.create(User, pk='id')This creates a new app.db database in the /data folder if a database doesn’t already exist there. Then, a class is created to specify the schema of the database, and this is used to create a users table. Note, similar to the database, the users table will only be created if it doesn’t already exist.
With the database now available we can use it to store user details upon login (if not already done so).
class Auth(OAuth):
def get_auth(self, info, ident, session, state):
if info.email_verified:
# Download avatar if available
avatar_data = None
avatar_url = getattr(info, 'picture', None)
if avatar_url:
# Fix Google avatar URL for better compatibility
if 'googleusercontent.com' in avatar_url and '=' in avatar_url:
base_url = avatar_url.split('=')[0]
avatar_url = f"{base_url}=s64"
# Download the avatar
try:
response = requests.get(avatar_url, timeout=10)
if response.status_code == 200:
avatar_data = response.content
else:
print(f"Avatar download failed: {response.status_code}")
except Exception as e:
print(f"Avatar download error: {e}")
avatar_data = None
# Store user info in database if not already there
user = users(where=f"email='{info.email}'")[0]
if not user:
u = User(
email=info.email,
name=info.name or email.split('@')[0],
created_at=datetime.now().isoformat(),
last_login=datetime.now().isoformat(),
avatar_data=avatar_data
)
user = users.insert(u)
else:
user.last_login = datetime.now().isoformat()
users.update(user)
# Store user info in session
session["user_id"] = user.id
session["user_name"] = user.name
session["user_given_name"] = info.given_name
session["user_email"] = user.email
session["user_has_avatar"] = bool(user.avatar_data) # Just store whether avatar exists
# Store the access token in session for later revocation
if hasattr(self, 'cli') and hasattr(self.cli, 'token'):
session['access_token'] = self.cli.token.get('access_token')
# Check if there's a stored intended destination from when user was redirected to login
intended_url = session.get('intended_url')
if intended_url and intended_url != '/login':
session.pop('intended_url', None) # Remove after using
return RedirectResponse(intended_url, status_code=303)
# If user logged in from /login route, redirect to homepage
if state and state.endswith('/login'):
redirect_url = '/'
else:
redirect_url = state if state else '/'
return RedirectResponse(redirect_url, status_code=303)Most of the new code in get_auth() is to handle the avatar image and store it as blob data (Binary Large Object). We’re also updating the session information so we can directly retrieve user information without having to ping the database. We also need to udate the /revoke endpoint to clean up the extra session data:
# Clear the session of user data
sess.pop('auth', None)
sess.pop('access_token', None)
sess.pop('user_id', None)
sess.pop('user_name', None)
sess.pop('user_given_name', None)
sess.pop('user_email', None)
sess.pop('user_has_avatar', None)We can now update the NavComponent to make use of the logged in user session data to display name and avatar.
user_id = sess.get('user_id')
auth_links = Div(
Img(src=f'/avatar/{user_id}', cls='w-8 h-8 rounded-full') if sess.get('user_avatar') else Div(
sess.get('user_given_name', 'U')[0].upper(),
cls='w-8 h-8 rounded-full bg-gray-500 flex items-center justify-center text-white text-sm font-medium'
),
P(f'Welcome back {sess.get('user_given_name')}!'),
Span('|'),
A('Log out', href='/logout', cls='hover:text-blue-600'),
Span('|'),
A('Revoke', href='/revoke', cls='hover:text-red-600'),
cls='flex items-center gap-2'
)To make this work we added a new route to handle loading of the avatar from the database as it is too large to store in session data.
@rt('/avatar/{user_id}')
def get_avatar(user_id: int):
user_list = users(where=f"id={user_id}")
if user_list and user_list[0].avatar_data:
user = user_list[0]
return Response(
content=user.avatar_data,
media_type="image/png"
)
else:
return Response(status_code=404)Now we see user specific data when a user completes login.

Finishing Touches
Finally, to make the UI look a little more polished I added the logout, and user details to a dropdown menu, and also added a new dashboard route that only appears in the dropdown when a user is logged in. I won’t post the code changes here as the updates are pure HTML, CSS and JS only. They don’t affect the logic of logging in via Google OAuth. But I thought it would be nice to finish on a decent looking demo that you can use as a starting point for your own apps.
Here is the revised dropdown menu that is only displayed when logged in:

And here is the new dashboard route:

Note: The dashboard is a protected route just like the image generation route, and so if you attempt to access this when logged out you’ll be redirected to the login route.
Inspecting the Databse
When developing and testing apps that use an SQLite database it is often very handy to be able to easily access the database and view the schema and content for all tables in the database, and even add and delete data too! There are many tools out there for this but one I use quite often is sqlite-web. This runs in the browser and is very easy to install and use.
All we have to do is add the sqlite-web dependency to your pyproject/toml file and open up our project directory in a new separate terminal to the one we’re running the FastHTML server in. So, we’ll have two terminals running, one for the FastHTML app (the local webserver), and the other for the sqlite-web appication.
[project]
name = "google-oauth"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = ["python-fasthtml", "python-dotenv", "requests", "sqlite-web"]Enter this to run sqlite-web:
uv run sqlite-web ./data/app.db

How cool is that! I can’t underestimate how useful it is to have such a tool easily available to quickly view the status of the database during development to make sure data is being created, stored, and updated as expected.
Access the Full Code
The complete code for the FastHTML app, together with full usage instructions, can be found in the associated GitHub repository.
Follow Me For More Content
Thanks for reading! And if you liked this post please consider following me on Twitter and LinkedIn for more ML and AI related content.