Skip to main content

How to Build a Modern Flask Sign-In & Sign-Up Page with HTMX?

 

How to Build a Modern Flask Sign-In & Sign-Up Page with HTMX?

A great sports club website is more than an attractive landing page—it should also provide a secure and seamless experience for its members. In this tutorial, we'll transform the Jersey City Pickleball Club website by building a modern Sign In and Sign Up system using Flask, Bootstrap 5, HTMX, TinyDB, and Flask-Login. You'll learn how to create a responsive authentication interface with smooth partial page updates, securely hash user passwords, manage user sessions, and protect member-only pages, all while keeping the codebase clean, lightweight, and easy to maintain.


Prerequisite:

This tutorial is part of the Flask Landing Page and Reservation System Series.

📚 View the Complete Flask Landing Page and Reservation System Series

⬅ Previous Part                                                                                                                             ➡ Next Part

Preliminary:

Before I begin, please activate the virtual environment and install the required dependencies.
python -m venv venv
venv\Scripts\activate
pip install flask flask_login tinydb flask_wtf
Since Werkzeug comes with Flask as a standard library, I need not install it. 

Then, we need to set up the file and folder structure, as below:
   Files and folders
Under the Python file as a backend, I have the following files:
  1. - basically all the routes are in this file,
  2. db.py -  setting up user authentication management and creating a simple JSON database,
  3. form.py - creates a separate form and defines the field for each form, and
  4. users.py - defines a User class using Flask-Login's UserMixin.
Let me now explain the frontend files:
  1. base.html - this file contains the Bootstrap, HTMX, and custom CSS links and CDNs. Therefore, I simply call out the content and need not repeat it in other HTML files.
  2. dashboard.html - the destination page after the user's successful sign-in,
  3. new_signin.html - Flask backend returns just the sign-up form without reloading the entire page,
  4. privacy - loading the  privacy page entirely,
  5. terms_conditions - loading the terms and conditions entirely
The partials folder under the templates folder contains the following files:
  1. auth_box.html - it contains the 2 tabs, including the sign-in and sign-up tabs. Each tab is attached to the respective form.
  2. signin_form.html - a description of the fields of the sign-in form,
  3. signup_form.html - a description of the fields of the sign-up form.
The static folder is a hub for images and stylesheets.

Step 1: Set up the DB and user files.
db.py
Create the DB and table
from tinydb import TinyDB, Query
from user import User
from flask_login import LoginManager

db = TinyDB("users.json")
users_table = db.table("users")
UserQuery = Query()

login_manager = LoginManager()

@login_manager.user_loader
def load_user(user_id):
    doc = users_table.get(doc_id=int(user_id))
    if doc:
        return User(doc)
    return None
I create a TinyDB "users.json," then create a users table, and create a query object used for searching and filtering through those user records later. 

LoginManager is initialising the Session Manager, which handles user sessions behind the scenes. It keeps track of who is securely logged in, manages session cookies, and handles logging users out.

Therefore, every single time a logged-in user refreshes the page or clicks a new link, Flask-Login takes the user_id stored inside their secure browser cookie.

On the other hand, if no match is found (e.g., the user doesn't exist or was deleted), it treats them as a logged-out guest.

user.py
Create a login session
from flask_login import UserMixin

class User(UserMixin):
    def __init__(self, user_data):
        self.id = str(user_data.doc_id)      
        self.username = user_data["username"]
        self.password_hash = user_data["password_hash"]
Even though I use TinyDB to create a users.json. However, Flask-Login does not know anything about my database. It only knows how to manage a user session. Therefore, I need to provide a user class that represents a logged-in user. 

For example, when a new user signs up, TinyDB will create a user's ID, for example, ID: 1, and the username is Kelvin; then, Flask-Login will store this ID in the session.

Why use an ID?

An ID is unique and will not change. However, the username may change over time and sometimes contain special characters.

While the UserMixin is bridging between my simple JSON database and Flask-Login's authentication system. It is a helper class provided by Flask-Login that gives my User object the methods and properties that Flask-Login expects.


Step 2: Configure the base.html and build a basic background layout
base.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>User-Login | App{% block title %} {% endblock %}</title>
    
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/
         dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="{{ url_for('static', 
            filename=' ') }}"> </head> <body class="bg-light"> {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} <div class="alert alert-{{ category }} alert-dismissible fade show"> {{ message }} <button type="button" class="btn-close" data-bs-dismiss="alert"></button> </div> {% endfor %} {% endif %} {% endwith %} {% block content %} {% endblock %} <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/ dist/js/bootstrap.bundle.min.js"></script> <script src="https://unpkg.com/htmx.org@2.0.0"></script> </body> </html>
This is a 'mother' for every HTML page where I have located 
  1. Bootstrap CDN, 
  2. HTMX CDN and 
  3. My custom CSS location.
So, when I add the following code, the Jinja template engine will be effective across all the other pages:
{% extends 'base.html' %}
{% block content %}
Content here
{% endblock %}
It will load those CDNs and links without the need to repeat them. This aligns with the philosophy of not repeating it in coding. 

Here is exactly how it works behind the scenes when Flask renders this template:

  1. Inheritance: {% extends 'base.html' %} tells Flask to grab the entire structure of your base file (including the <head>, Bootstrap CSS/JS, and HTMX scripts).

  2. Injection: Flask takes the "Content here" text from your child template and injects it right into the middle of the body where it {% block content %}{% endblock %} is defined in the base layout.

  3. The Result: The browser receives a single, complete HTML page that contains all your CDN dependencies alongside your specific page content.

Then, I go ahead and create my home page layout without any form:
Configure the base.html and build a basic background layout

app.py
Show the basic route
from flask import (Flask, render_template, redirect, url_for, 
                   flash, make_response)
from flask_login import  login_required,login_user, logout_user
                      
from werkzeug.security import (generate_password_hash, 
                               check_password_hash)

from form import SigninForm, SignupForm, SignoutForm
from db import login_manager, users_table, UserQuery
from user import User

app=Flask(__name__)
app.secret_key = 'your_secret_key'  

@app.route('/')
def home():
    return render_template(
        'new_signin.html',
        form=SigninForm()
    )
First of all, initialise my Flask application and set a secret key, which Flask uses behind the scenes to securely encrypt session cookies.

Next, anyone who visits your main URL will immediately be presented with the new_signin.html template. 

new_signin.html
{% block title %}New Sign In{% endblock %}
{% block content %} 

<section class="hero d-flex flex-column align-items-center justify-content-center">
    <img src="{{ url_for('static', filename='assets/title.png') }}"
         class="hero-title mb-4">

     <div class="card shadow-sm w-100" style="max-width:400px;">

        <div id="form-container"
             hx-get="{{ url_for('signin_form') }}"
             hx-trigger="load"
             hx-swap="innerHTML">
        </div>
    </div>
    
    <footer class="footer">
        <div class="footer-content text-body-secondary small">
            <a href="{{ url_for('privacy') }}" 
class="text-decoration-none text-body-secondary">Privacy</a> <span>|</span> <a href="{{ url_for('terms_conditions') }}"
class="text-decoration-none text-body-secondary">Terms & Condition</a> <span>|</span> <span>NJPC.right reserved @ 2026</span> </div> </footer> </section> {% endblock %}

style.css

body, html {
    margin: 0;
    padding: 0;
    height: 100%;
}

.hero {
    min-height: 100vh; 
    width: 100%;
    position: relative;
    background:
        linear-gradient(
            rgba(0,0,0,.5),
            rgba(0,0,0,.5)
        ),
        url("../static/assets/login.jpg");
    background-size: cover;
    background-position: center center;
    padding-bottom: 80px;
}

/* Title image - No hardcoded positioning needed */
.hero-title {
    max-width: 100%;   
    width: 800px;    
    height: auto;
    z-index: 2;
}
  
.footer {
    /* Remove absolute positioning so it cannot overlap */
    margin-top: 20px; 
    
    /* Shrink-wrap configuration */
    width: max-content;
    max-width: 95%;
    background-color: #ffffff !important;
    
    /* Spacing and Styling */
    padding: 8px 20px;          
    border-radius: 30px;        
    box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05); 
    text-align: center;         
}

/* Keeps everything horizontally aligned in 1 single row */
.footer-content {
    display: flex;
    align-items: center;
    gap: 10px; 
    margin: 0;
    white-space: nowrap;
}

(i) Hero Section (<section>)
Bootstrap/custom classes used here are

  • hero is a custom CSS class that styles the hero section by applying the background image, height, overlay, and overall appearance of the authentication page.
  • d-flex is a Bootstrap Flexbox utility class that enables the section to use the Flexbox layout.
  • flex-column is a Bootstrap utility class that arranges all child elements vertically from top to bottom.
  • align-items-center is a Bootstrap utility class that horizontally centers all elements within the hero section.
  • justify-content-center is a Bootstrap utility class that vertically centers the content within the hero section.

(ii) Title Image (<img>)
Bootstrap/custom classes used here are

  • hero-title is a custom CSS class that controls the size and responsiveness of the Jersey City Pickleball Club title image.
  • mb-4 is a Bootstrap spacing utility class that adds a bottom margin to create space between the title image and the authentication card.

(iii) Authentication Card (<div>)
Bootstrap classes used here are

  • card is a Bootstrap component that creates a clean container with rounded corners for displaying the Sign In and Sign Up forms.
  • shadow-sm is a Bootstrap utility class that adds a subtle shadow around the card, giving it a slightly elevated appearance.
  • w-100 is a Bootstrap width utility class that allows the card to occupy the full available width of its parent container while respecting the maximum width specified by the inline style.
  • max-width: 400px is an inline CSS style that limits the maximum width of the card to 400 pixels, ensuring a compact and readable layout on larger screens.

(iv) HTMX Form Container (<div id="form-container">)
The HTMX attributes used here are the following:

  • id="form-container" uniquely identifies the container where the Sign In and Sign Up forms are dynamically loaded.
  • hx-get sends an asynchronous HTTP GET request to the specified Flask route to retrieve the required form.
  • hx-trigger="load" automatically triggers the request when the page has finished loading, displaying the Sign In form by default.
  • hx-swap="innerHTML" replaces only the contents inside the form-container element with the HTML returned by the Flask route, avoiding a full page refresh.

(v) Footer (<footer>)
Bootstrap/custom classes used here are

  • footer is a custom CSS class that styles the footer's position, spacing, and appearance.
  • footer-content is a custom CSS class that controls the alignment and layout of the footer content.
  • text-body-secondary is a Bootstrap utility class that applies a secondary text colour, making the footer less visually dominant.
  • small is a Bootstrap typography utility class that displays the footer text in a smaller font size.
  • text-decoration-none is a Bootstrap utility class that removes the default underline from hyperlinks.
  • text-body-secondary is also applied to the hyperlinks to ensure they match the footer's text colour and maintain a consistent appearance.

Step 3: Create a sign-in and sign-up form and configure the route 
form.py
Flask-wtf is used to define my web form and the fields in each form
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms import validators

class SigninForm(FlaskForm):
    username = StringField('Username', 
                           [validators.DataRequired('Username')])
    password = PasswordField('Password', 
                             [validators.DataRequired('Password')])
    submit = SubmitField('Sign In')

class SignupForm(FlaskForm):
	username = StringField("Username", [validators.DataRequired('Username')])
	email = StringField("Email", [validators.DataRequired('Email')])
	password = PasswordField('Password', [validators.DataRequired('Password')])
	submit = SubmitField("Sign Up")

Why use Flask-WTF?

Flask-WTF simplifies form handling by combining WTForms with Flask-specific features. Instead of manually retrieving values using code, developers can define forms as Python classes, automatically validate user input, generate HTML form fields, and benefit from built-in CSRF protection. This approach results in cleaner, more secure, and easier-to-maintain code.

(i) StringField

StringField creates a single-line text input field that allows users to enter textual information, such as a username or email address.


(ii) PasswordField

PasswordField creates a password input field. Unlike a StringField, the characters entered by the user are automatically masked in the browser to improve security and privacy.


(iii) SubmitField

SubmitField creates a submit button that allows the user to send the completed form to the Flask application for processing.


(iv) validators.DataRequired()

The DataRequired() validator ensures that a form field is not left empty before the form is submitted. If the user submits an empty field, validation fails, and the specified error message is displayed.


(v) SigninForm
This class defines the form used for user authentication. It inherits from FlaskForm and contains three fields:
  • username – accepts the user's username.
  • password – accepts the user's password and masks the input.
  • submit – creates the Sign In button.
Before the form is processed, Flask-WTF validates that both the username and password fields are not empty.

(vi) SignupForm
This class defines the registration form for creating a new user account. It also inherits from FlaskForm and contains four fields:
  • username – allows the user to choose a username.
  • email – accepts the user's email address.
  • password – accepts the user's password and masks the input.
  • submit – creates the Sign Up button.
Each field uses the DataRequired() validator to ensure that all required information is entered before the registration form is submitted.

extracted app.py
login_manager.init_app(app)
login_manager.login_view = "signin"

@app.route("/signin/form")
def signin_form():
    form = SigninForm()
    return render_template(
        "partials/auth_box.html",
        active_tab="signin",
        form_template="partials/signin_form.html",
        form=form
    )
    
@app.route("/signup/form")
def signup_form():
    form = SignupForm()
    return render_template(
        "partials/auth_box.html",
        active_tab="signup",
        form_template="partials/signup_form.html",
        form=form
    )
(i) login_manager.init_app(app)

This method initialises Flask-Login and attaches it to the Flask application.

  • login_manager is an instance that manages user authentication, login sessions, and user loading.
  • init_app(app) registers the user LoginManager with the Flask application so that Flask-Login can manage user sessions and authentication throughout the application.

Without this line, Flask-Login is not connected to your Flask application, and features such as login_user(), logout_user(), current_user, and @login_required will not function correctly.


(ii) login_manager.login_view = "signin"
This property specifies the login page that unauthenticated users are redirected to when they attempt to access a protected route. login_view stores the endpoint name of the login route.

(iii) @app.route("/signin/form")
This decorator defines a Flask route that responds to HTTP GET requests sent to /signin/form. In this project, the route is called by HTMX to dynamically load the Sign In form into the authentication card without refreshing the entire webpage.


(iv) def signin_form():
This function handles requests made to the /signin/form route. Its purpose is to create a new Sign In form and return the HTML required to display it.


(v) form = SigninForm()
This statement creates an instance of the SigninForm class. The form object contains all the input fields, validation rules, and CSRF protection required for the Sign In page.


(vi) @app.route("/signup/form")
This decorator defines a Flask route that responds to HTTP GET requests sent to /signup/form. In this project, the route is called by HTMX to dynamically load the sign-up form without refreshing the entire webpage.


(vii) form = SignupForm()
This statement creates an instance of the SignupForm class. The form object contains all the fields, validation rules, and CSRF protection required for the user registration form.


(viii) render_template()
This statement renders the auth_box.html template and passes the following variables to it:
  • active_tab="signin" or "signup" identifies the Sign In and Sign Up tabs, respectively, as the currently active tabs. 
  • The template uses this value to apply the appropriate Bootstrap classes so that the active tab is visually highlighted.
  • form_template="partials/signin_form.html" or "partials/signup_form.html" specifies the partial template that contains the sign-in or sign-up form.
  • The auth_box.html template dynamically includes this file using Jinja's {% include %} statement.
form=form passes the SigninForm or SignupForm object to the template, allowing Jinja to render the username, password, and submit button.

How to switch between the sign-in and sign-up tabs?

Now, when I click the sign-in tab, the sign-in form will be displayed. Meanwhile, when I click the sign-up tab, the sign-up form will also be displayed.
How to switch between the sign-in and sign-up tabs?

auth_box.html
<ul class="nav nav-tabs nav-fill">
    <li class="nav-item">

        <a class="nav-link 
        {% if active_tab == 'signin' %}
            active bg-dark text-white
        {% endif %}"

        hx-get="{{ url_for('signin_form') }}"
        hx-target="#form-container"
        hx-swap="innerHTML">
            Sign In
        </a>
    </li>
style.css
.nav-tabs .nav-link {
    color: #6c757d;
    border-radius: 8px 8px 0 0;
    transition: all 0.3s ease;
}

.nav-tabs .nav-link:hover {
    background-color: #f1f1f1;
    color: #000;
}

.nav-tabs .nav-link.active-tab {
    background-color: #212529;   /* dark background */
    color: white;
    font-weight: 600;
    border-color: #212529;
}
(i) Nav-Link and Active State (
  • nav-link: A Bootstrap class that styles the anchor tag as a clean navigation tab item.
(ii) HTMX Dynamic Routing Attributes
  • hx-get="{{ url_for('signin_form') }}": When a user clicks this "Sign In" tab, HTMX intercepts the click and sends an asynchronous AJAX GET request directly to my Flask view function, signin_form.
  • hx-target="#form-container": Tells HTMX exactly where to put the server's response. In this case, it targets the wrapper element with the ID form-container.
  • hx-swap="innerHTML": Instructs HTMX to replace everything inside that target container with the partial HTML returned by the Flask route (partials/signup_form.html inside my auth_box.html layout), giving me a smooth transition without triggering a hard browser reload.
(i) signup.html 
{% with messages = get_flashed_messages() %}
    {% if messages %}
        {% for message in messages %}
            <div class="alert alert-warning alert-dismissible 
               fade show" role="alert">
                {{ message }}
                <button type="button"
                        class="btn-close"
                        data-bs-dismiss="alert">
                </button>
            </div>
        {% endfor %}
    {% endif %}
{% endwith %}

<form
    hx-post="{{ url_for('signup') }}"
    hx-target="#form-container"
    hx-swap="innerHTML">

    {{ form.hidden_tag() }}
    {{ form.username.label }}
    {{ form.username(class="form-control") }}

    <br>
    {{ form.email.label }}
    {{ form.email(class="form-control") }}

    <br>
    {{ form.password.label }}
    {{ form.password(class="form-control") }}

    <br>
    {{ form.submit(class="btn btn-success w-100") }}
</form>
(ii) signin.html
{% with messages = get_flashed_messages() %}
    {% if messages %}
        {% for message in messages %}
            <div class="alert alert-warning alert-dismissible fade show">
                {{ message }}
                <button class="btn-close" data-bs-dismiss="alert"></button>
            </div>
        {% endfor %}
    {% endif %}
{% endwith %}

<form
    hx-post="{{ url_for('signin') }}"
    hx-target="#form-container"
    hx-swap="innerHTML">

    {{ form.hidden_tag() }}
    {{ form.username.label }}
    {{ form.username(class="form-control") }}

    <br>
    {{ form.password.label }}
    {{ form.password(class="form-control") }}

    <br>
    {{ form.submit(class="btn btn-primary w-100") }}
</form>

Their Similarities

  • Both are partial templates that are dynamically loaded into the authentication card using HTMX.
  • Both display Flask flash messages using get_flashed_messages() and Bootstrap's dismissible Alert component.
  • Both submit the form asynchronously using the hx-post attribute, eliminating the need for a full page refresh.
  • Both are used hx-target="#form-container" to specify that only the authentication card should be updated.
  • Both use hx-swap="innerHTML" to replace the existing form with the server's response.
  • Both include {{ form.hidden_tag() renders the hidden CSRF token, protecting the application against Cross-Site Request Forgery (CSRF) attacks.
  • Both use Flask-WTF to automatically render form fields and perform server-side validation.
  • Both use Bootstrap classes to create responsive, modern-looking forms.
However, there are also differences between
Sign-In FormSign-Up Form
  • Submits the form to the /signin route.
  • Submits the form to the /signup route.
  • It contains username and password fields only.
  • It containsUsername, Email, and Password fields.
  • Uses a Sign In submit button with the Bootstrap btn-primary class.
  • Uses a Sign Up submit button with the Bootstrap btn-success class.
  • Displays login-related messages, such as "Invalid username or password."
  • Displays registration-related messages, such as "Username already exists." or "Account created successfully!"
  • Authenticates an existing user and creates a login session.
  • Registers a new user by storing the user's details in TinyDB after hashing the password.
---------------------------------------------------------------------------------------------------------------------------------------------------

🎁 Get Your FREE Flask Cheat Sheet

Get Your FREE Flask Cheat Sheet

Get more Flask, Python automation, Docker, and HTMX tutorials delivered to your inbox.

✓ Practical coding tutorials
✓ Automation tips for SMEs
✓ New project ideas and templates

Download my FREE Flask Cheat Sheet (PDF)

---------------------------------------------------------------------------------------------------------------------------------------------------


Step 4: Registers a new user 
The underlying logic
Registers a new user logic

extracted app.py
@app.route("/signup", methods=["POST"])
def signup():
    # Create an instance of the registration form
    form = SignupForm()

    # Process the form only if validation succeeds
    if form.validate_on_submit():

        # Check whether the username already exists
        existing = users_table.get(UserQuery.username == form.username.data)

        if existing:
            # Username is already taken
            flash("Username already exists.")

            return render_template(
                "partials/auth_box.html",
                active_tab="signup",
                form_template="partials/signup_form.html",
                form=form
            )

        # Save the new user with a hashed password
        users_table.insert({
            "username": form.username.data,
            "email": form.email.data,
            "password_hash": generate_password_hash(form.password.data)
        })

        # Registration successful
        flash("Account created successfully! Please sign in.")

        # Display the Sign In form
        return render_template(
            "partials/auth_box.html",
            active_tab="signin",
            form_template="partials/signin_form.html",
            form=SigninForm()
        )

    # Form validation failed
    return render_template(
        "partials/auth_box.html",
        active_tab="signup",
        form_template="partials/signup_form.html",
        form=form
    )

The registration flow

Signup route (app.py)

(i) Create Signup Form Instance

  • Creates an instance of the SignupForm class.
  • This object contains the username, email, password fields, CSRF token, and validation rules.
  • It allows Flask-WTF to process and validate the submitted data.

(ii) Validate Submitted Form
  • Checks whether:
    • The request method is POST.
    • All form fields pass validation rules.
    • The CSRF token is valid.
  • If validation succeeds, the registration process continues.
  • If validation fails, the user remains on the Sign Up form.

(iii) Check Existing Username (If user existed)
  • Searches the TinyDB users_table to check whether the submitted username already exists.
  • form.username.data retrieves the username entered by the user.
  • UserQuery.username is used by TinyDB to perform the database search.

(iv) Handle Duplicate Username

If the username is already registered:

  • Creates a Flask flash message.
  • Informs the user that they cannot use the same username.
  • UserQuery.username is used by TinyDB to perform the database search.
Reloads the authentication card while keeping the Sign Up tab active.
    • auth_box.html acts as the wrapper containing the tab navigation and form area.
    • active_tab="signup" highlights the Sign Up tab.
    • form_template="partials/signup_form.html" tells Jinja to display the sign-up form.
    • form=form passes the existing form object back so validation errors can be displayed

(v) Store New User (If a new user) 
  • Inserts a new user record into TinyDB.
  • Stores:
    • Username entered by the user.
    • Email address entered by the user.
    • Hashed password.

The password is not stored as plain text. This improves security because the original password cannot easily be recovered.

Json database


(vi) Display Success Message

  • Creates a success notification.
  • Informs the user that registration has completed.
  • Guides the user to log in using the newly created account.

(vii) Switch to Sign In Form

After successful registration:

  • The authentication card is refreshed.
  • The active tab changes from Sign Up to Sign In.
  • A new empty SigninForm() is created.
  • The user can immediately enter their username and password.

(viii) Handle Validation Failure

This handles cases such as the following:

  • Username is empty.
  • Email is empty.
  • Password is empty.
  • CSRF validation fails.

The user stays on the Sign Up tab, and the entered data is preserved where possible.


Step 5: Authentication of existing user

The underlying logic

Authentication of existing user logic
extracted app.py
@app.route('/signin', methods=['GET', 'POST'])
def signin():
    # Create an instance of the Sign In form
    form = SigninForm()

    # Validate the submitted form data
    if form.validate_on_submit():

        # Search for the user in TinyDB using the entered username
        user_data = users_table.get(
            UserQuery.username == form.username.data
        )

        # Check whether the user exists and verify the password hash
        if user_data and check_password_hash(
            user_data["password_hash"], 
            form.password.data
        ):

            # Create a logged-in user session using Flask-Login
            login_user(User(user_data))

            # Use HTMX redirect to navigate to the dashboard page
            response = make_response("")
            response.headers["HX-Redirect"] = url_for("dashboard")
            return response
        
        else:
            # Display an error message if username or password is incorrect
            flash("Invalid username or password.")

            # Reload the authentication box while keeping the Sign In tab active
            return render_template(
                "partials/auth_box.html",
                active_tab="signin",
                form_template="partials/signin_form.html",
                form=form
            )

    # Display the Sign In form when the page is first loaded
    # or when form validation fails
    return render_template(
        "partials/signin_form.html",
        form=form
    )

@app.route('/dashboard', methods=['GET', 'POST'])
@login_required
def dashboard():
    # Create the Sign Out form
    # This form sends a POST request to the signout route
    form = SignoutForm()

    # Render the protected dashboard page
    # Only authenticated users can access this page
    return render_template(
        'dashboard.html',
        form=form
    )

@app.route('/signout', methods=['POST'])
@login_required
def signout():
    # Remove the user's session and log them out
    logout_user()

    # Redirect the user back to the home page
    return redirect(url_for("home"))

The Authentication Flow

Sign In Route (app.py)

(i) @app.route('/signin', methods=['GET', 'POST'])

  • Defines the /signin route.
  • Accepts both GET and POST HTTP requests.
  • A GET request displays the Sign In form, while a POST request processes the submitted login credentials.

(ii) def signin():

  • Defines the view function that handles all requests made to the /signin route.
  • It is responsible for displaying the Sign In form, validating user credentials, and creating a login session.

(iii) Create the sign-in form.

  • Creates an instance of the SigninForm class. 
  • The form contains the username field, password field, submit button, validation rules, and CSRF protection.

(iv) Validate the Form
  • Checks whether:
    • The request method is POST.
    • All required fields have been completed.
    • The CSRF token is valid.
  • If validation succeeds, the authentication process continues.
  • Otherwise, the Sign In form is redisplayed.

(v) Retrieve the User from TinyDB

  • Searches the TinyDB users_table for a user whose username matches the one entered in the form.
  • form.username.data retrieves the username submitted by the user.
  • If a matching record is found, it is stored in user_data.

(vi) Verify the Password

  • First checks whether the user exists.
  • If the user exists, check_password_hash() compares the stored hashed password with the password entered by the user.
  • The user is authenticated only if both the username and password are correct.

(vii) Create a Login Session

  • Creates a login session using Flask-Login.
  • Converts the TinyDB record into a User object.
  • Stores the user's ID in the session so that Flask-Login can recognise the user on subsequent requests.
  • After this step, the user can access routes protected by @login_required.

(viii) Redirect Using HTMX (If password matches - a full reload to dashboard page)

  • Creates an empty HTTP response.
  • Adds the HX-Redirect response header, instructing HTMX to redirect the browser to the dashboard page.
  • This approach enables navigation without requiring a traditional HTTP redirect.

(ix) Handle Invalid Credentials (If password mismatch)

  • Creates a flash message if authentication fails.
  • The message is displayed to the user using the Bootstrap alert component in the Sign In form.

(x) Reload the Sign In Form

  • Redisplays the authentication card.
  • Keeps the Sign In tab active.
  • Reloads the Sign In form with the flash message visible.

(xi) Display the Initial Sign-In Form

  • Handles the initial GET request to /signin.
  • Renders the Sign In form when the page is first loaded or when form validation fails before authentication is attempted.

Dashboard Route (app.py)

(i) @app.route('/dashboard', methods=['GET', 'POST'])

  • Defines the /dashboard route.
  • Accepts both GET and POST HTTP requests.
  • This route displays the dashboard page after a user has successfully signed in.

(ii) @login_required

  • Protects the dashboard route so that only authenticated users can access it.
  • If an unauthenticated user attempts to access /dashboard, Flask-Login automatically redirects them to the login page

(iii) form = SignoutForm()

  • Creates an instance of the SignoutForm.
  • The form contains the Sign Out button and a hidden CSRF token to protect the logout request.

(iv) return render_template('dashboard.html', form=form)

  • Renders the dashboard.html template.
  • Passes the SignoutForm object to the template so that Jinja can render the Sign Out button.

Sign Out Route (app.py)

(i) @app.route('/signout', methods=['POST'])

  • Defines the /signout route.
  • Accepts only POST requests.
  • Using POST for logout is more secure than using GET because it prevents accidental logouts through bookmarked URLs or hyperlinks.

(ii) @login_required

  • Ensures that only authenticated users can access the sign-out route.
  • Prevents unauthenticated users from sending logout requests.

(iii) logout_user()

  • Ends the current user's authenticated session.
  • Removes the user's login information stored by Flask-Login.
  • After calling this function, the user is no longer authenticated.

(iv) return redirect(url_for("home"))

  • Redirects the user to the home page after successfully signing out.
  • url_for("home") generates the URL for the home() route dynamically.
dashboard.html
{% with messages = get_flashed_messages() %}
    {% if messages %}
        {% for message in messages %}
            <div class="alert alert-warning alert-dismissible 
                fade show" role="alert">
                {{ message }}
                <button type="button" class="btn-close" 
                   data-bs-dismiss="alert"></button>
            </div>
        {% endfor %}
    {% endif %}
{% endwith %}
{% extends 'base.html' %}
{% block title %}Dashboard{% endblock %}
{% block content %}    
<div class="container d-flex justify-content-center 
				align-items-center min-vh-100"</p>
    <div class="card shadow-lg border-0 w-100" 
    			style="max-width: 500px; border-radius:15px;</p>
        
        <div class="card-header bg-primary text-white text-center py-3">
            <h3 class="mb-0">Dashboard</h3>
        </div>
        
        <div class="card-body p-4 text-center">
            <h5 class="mb-3">Welcome 🎉</h5>
            <p class="text-muted mb-4">You have successfully logged in.</p>
            <form method="POST" action="{{ url_for('signout') }}">
                {{ form.hidden_tag() }}
                {{ form.submit(class="btn btn-danger w-100 py-2 fw-semibold") }}
            </form>
        </div>
        <div class="card-footer text-muted text-center small">
            Flask + Bootstrap Dashboard
        </div>
    </div>
</div>
{% endblock %}
(i) Flash Messages
  • Retrieves flash messages created by Flask using the flash() function.
  • Displays messages such as successful login or logout using Bootstrap's dismissible alert component.

(ii) Bootstrap Container
  • container provides responsive horizontal padding.
  • d-flex enables Flexbox.
  • justify-content-center centers the dashboard horizontally.
  • align-items-center centers the dashboard vertically.
  • min-vh-100 makes the container occupy at least the full height of the browser viewport.

(iii) Dashboard Card
  • Creates a Bootstrap card to display the dashboard content.
  • shadow-lg adds a large shadow.
  • border-0 removes the default border.
  • w-100 allows the card to use the available width of its container.

(iv) Card Header
  • Displays the dashboard title.
  • bg-primary applies Bootstrap's primary background colour.
  • text-white changes the text colour to white.
  • text-center centers the heading.
  • py-3 adds vertical padding.

(vi) Card Body
  • Contains the main dashboard content.
  • Displays a welcome message after successful authentication.
  • p-4 adds padding around the content.
  • text-center centers the text.

(vii) Sign Out Form
  • Creates the logout form.
  • Submits a POST request to the /signout route.
  • Uses url_for() to generate the logout URL dynamically.
  • Renders the hidden CSRF token to protect against Cross-Site Request Forgery (CSRF) attacks.
  • Renders a Bootstrap-styled Sign Out button.
  1. btn-danger gives the button a red appearance.
  2. w-100 makes the button span the full width of the card.
  3. py-2 adds vertical padding.
  4. fw-semibold applies a semi-bold font weight.



Step 6: Set up privacy, terms and conditions
Since the privacy and terms and conditions are similar, I was only displaying the privacy route and page.

extracted app.py
@app.route('/privacy')
def privacy():
    return render_template('privacy.html')
  • Defines the /privacy route. 
  • Renders the privacy.html template.
  • Returns the completed HTML page to the user's browser.
  • Since the Privacy page contains only static information, no additional data needs to be passed to the template.

privacy.html
{% extends 'base.html' %}

{% block title %}Privacy{% endblock %}

{% block content %} 
<div class="container d-flex justify-content-center align-items-center min-vh-100">
    <div class="card shadow-lg border-0 w-100" style="max-width: 600px; 
border-radius: 16px;"> <div class="card-header bg-primary text-white text-center py-3"> <h1 class="h3 mb-0">Privacy</h1> </div> <div class="card-body p-4 text-center text-muted"> <p>Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.</p> <p class="mb-0">Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.</p> </div> <div class="card-footer bg-transparent border-0 d-flex justify-content-center pb-4"> <a href="{{ url_for('home') }}" class="btn btn-success px-4 py-2 fw-semibold">Back to Home</a> </div> </div> </div> {% endblock %}

Set up privacy, terms and conditions

Privacy Template (privacy.html)

(i) Bootstrap Container

Bootstrap classes used here are:

  • container creates a responsive container that automatically adjusts its width based on the screen size.
  • d-flex enables the Flexbox layout.
  • justify-content-center horizontally centers the privacy card.
  • align-items-center vertically centers the privacy card.
  • min-vh-100 ensures the container occupies at least the full height of the browser viewport.

(ii) Privacy Card

Bootstrap/custom styles used here are:
  • card creates a Bootstrap card to display the Privacy Policy.
  • shadow-lg applies a large shadow to give the card a raised appearance.
  • border-0 removes the default card border.
  • w-100 allows the card to occupy the full available width of its container.
  • max-width: 600px limits the maximum width for improved readability.
  • border-radius: 16px rounds the card corners to create a modern appearance.

(iii) Card Body

Bootstrap classes used here are:
  • card-body contains the main Privacy Policy content.
  • p-4 adds padding around the text for better readability.
  • text-center centers the text within the card.
  • text-muted applies a lighter text colour to improve visual appearance.
The <p> elements contain the Privacy Policy text. In your example, placeholder Lorem Ipsum text is used, which can later be replaced with the actual privacy policy.

(iii) Card Footer

Bootstrap classes used here are:
  • card-footer creates the footer section of the card.
  • bg-transparent removes the default footer background colour.
  • border-0 removes the footer border.
  • d-flex enables Flexbox.
  • justify-content-center centers the button horizontally.
  • pb-4 adds bottom padding to create spacing below the button.

(ix) Back to Home Button

Bootstrap classes used here are:
  • btn styles the hyperlink as a Bootstrap button.
  • btn-success applies the Bootstrap success (green) colour scheme.
  • px-4 adds horizontal padding to increase the button width.
  • py-2 adds vertical padding to improve the button height.
  • fw-semibold displays the button text with a semi-bold font weight.
The url_for('home') function dynamically generates the URL for the home page, allowing users to return to the application's main page. 
 
Final wrap-up: 
This tutorial demonstrated how to build a complete user authentication system for the Jersey City Pickleball Club using Flask, TinyDB, Flask-Login, Flask-WTF, Bootstrap 5, and HTMX. Users can register for a new account, securely sign in using hashed passwords, access a protected dashboard, and sign out safely through Flask-Login's session management. By combining Bootstrap's responsive interface with HTMX's partial page updates, the application delivers a smooth user experience without requiring complex JavaScript. This lightweight architecture is well suited for small to medium-sized Flask applications, providing a solid foundation that can be extended with features such as email verification, password reset, user profiles, and role-based access control.

---------------------------------------------------------------------------------------------------------------------------------------------------

Thanks for reading! 

If you haven't subscribed yet, join my newsletter to receive future Python and Flask tutorials.

---------------------------------------------------------------------------------------------------------------------------------------------------

Need a similar system for your business? 
I build custom Flask web applications and Python automation solutions for SMEs and solopreneurs. 

---------------------------------------------------------------------------------------------------------------------------------------------------

About the Author 
Kelvin Loh is a Python developer focused on Flask, desktop applications, and business automation solutions. He shares practical tutorials and real-world coding projects to help developers and small businesses build useful applications.
-------------------------------------------------------------------------------------------------------------------------------------------------------
 

Comments

Popular Posts

How to Build an Audiobook Workflow Desktop System with Python?

In this tutorial, we will build a simple audiobook player using Python and CustomTkinter. You will learn how to convert text into speech using gTTS and play it with PyGame. We will also implement play, pause, and stop controls, like those in a real audio player. By the end, you will have a clean and functional desktop audiobook app. Prerequisite: This tutorial is part of the standalone tutorial. 📚 View the standalone tutorial Preliminary   Before I begin, it is recommended to activate the virtual environment before installing the relevant dependencies. python -m venv venv venv\Scripts\activate pip install customtkinter pillow gTTS pygame CTkMessagebox pypdf Then, the following steps include setting up the file structure, app.py, and two additional folders: the uploads and media folders. The media folder contains the icons necessary to build the app; there are read, pause, and stop icons, as shown on the diagram. Step 1: Build up the app interface I have 4 sections here: the...

How to Set Up PgAdmin and Adminer Using Docker Compose?

If you are new to database management with Docker, this tutorial will guide you through setting up both PgAdmin and Adminer using Docker containers. By containerising these tools, you can quickly launch lightweight and portable database management environments without installing them directly on your operating system. This approach also makes it easier to manage configurations, updates, and multiple projects across different devices.