Most web apps need the same front door. People create an account, log in and reach pages that strangers can't see. In this tutorial, we'll build a login and registration system with Python Flask and MySQL, from the database table to the logout button.

By the end, you'll have a working registration page, a login page and two private pages. You'll also understand why each security step is there.

In short

To build a login system in Python, fetch the account with a prepared statement, verify the password with check_password_hash() and store the account ID in Flask's session. Hash new passwords with generate_password_hash() before you store them.

Want the finished code? The free download and the Advanced Package are at the end of this tutorial.

1. Getting Started

Before we start, you'll need Python, a MySQL server and a few free tools.

1.1. Requirements

  • Python 3.10 or newer. Download it from python.org, or follow our guide to installing Python.
  • MySQL 8.0 or newer. Install MySQL Community Server, and choose a root password when the installer asks for one. MariaDB works too.
  • MySQL Workbench. This free app lets you run SQL files and see what's inside your tables. Download it from the MySQL Workbench download page.
  • A code editor and a terminal. We recommend VS Code. It's free and has a terminal built in.
  • Some Python and HTML. You should know HTML tags and Python basics such as variables, functions, if statements and dictionaries. Our Python lessons cover all of these. You don't need any Flask experience.

1.2. File Structure and Setup

Create an empty folder called pythonlogin and open it in VS Code. Then use the New File and New Folder buttons in the Explorer panel to add these files and folders. The files can be empty for now, because we'll fill each one in as we go:

File Structure

\-- pythonlogin
  |-- .env
  |-- main.py
  |-- schema.sql
  |-- static
    |-- favicon.svg
    |-- icons.svg
    |-- script.js
    |-- style.css
  \-- templates
    |-- home.html
    |-- index.html
    |-- layout.html
    |-- profile.html
    \-- register.html

Next, choose Terminal > New Terminal in VS Code. The terminal opens inside your project folder. If you use a different editor, create the files there, then open any terminal and use cd to move into the folder.

Check your Python version with python --version. It should show 3.10 or newer. On macOS and Linux, type python3 instead of python, both here and in the next command.

Now create a virtual environment. A virtual environment keeps this project's packages separate from your other Python projects:

Terminal
python -m venv .venv

Then activate it. On Windows, run:

Terminal
.venv\Scripts\activate

On macOS and Linux, run:

Terminal
source .venv/bin/activate

Your terminal prompt should now start with (.venv). If PowerShell says that running scripts is disabled, follow the fix for blocked activation scripts.

With the virtual environment active, install the five packages the app needs:

Terminal
pip install flask flask-wtf flask-limiter mysql-connector-python python-dotenv

Remember to activate the virtual environment again whenever you open a new terminal.

We tested this tutorial with Python 3.14, MariaDB 11.7, Flask 3.1.3, MySQL Connector/Python 26.7.0, Flask-WTF 1.3.0, Flask-Limiter 4.1.1 and python-dotenv 1.2.3.

2. How the Login System Works

Before we write any code, here's how the login system works.

2.1. Flask in One Minute

A route connects a URL to a Python function. When someone opens that URL, their browser sends a GET request and Flask runs the function. When they submit a form, the browser sends a POST request instead.

The function sends back a page, which Flask builds from a template. A template is an HTML file with placeholders, written in Flask's template language, Jinja.

Each route function has an @app.route line above it. This line is called a decorator, and it tells Flask which URL the function handles.

2.2. The Six Steps of a Login

The login system works in six steps:

  1. Register. A visitor fills in the registration form at /register.
  2. Hash. The app checks the form, turns the password into a hash and saves the new account in MySQL. A hash is a scrambled version of the password that can't be turned back into the original.
  3. Log in. The visitor enters their username and password on the login form, which sits at the site's main address, /.
  4. Verify. The app looks up the account and checks the password against the stored hash.
  5. Remember. If the password is right, Flask saves the account ID in a session cookie and signs it with a secret key that only your app knows. You'll set this key in section 4.
  6. Protect. The /home and /profile pages check the session. Visitors who aren't logged in are sent to the login page.

Why a cookie? A web server doesn't remember visitors between requests. Instead, the browser sends the session cookie with every request, and that's how the app knows who's logged in.

Anyone who has the cookie can read what's inside it, so the app never stores a password there. The signature means nobody can change the cookie without the app noticing. Logging out at /logout clears the session.

3. Setting Up the MySQL Database

The app stores every account in a single MySQL table called accounts.

Open schema.sql and add:

SQL schema.sql
CREATE DATABASE IF NOT EXISTS `pythonlogin`
    DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE `pythonlogin`;

CREATE TABLE IF NOT EXISTS `accounts` (
    `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
    `username` varchar(20) NOT NULL,
    `password` varchar(255) NOT NULL,
    `email` varchar(100) NOT NULL,
    `registered` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `username` (`username`),
    UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

This creates a database called pythonlogin with an accounts table inside it. The password column holds up to 255 characters, which is plenty for the 162-character password hashes the app creates. MySQL fills in the registered date automatically.

The unique keys stop two accounts from sharing a username or an email address. Because of the utf8mb4_unicode_ci setting, these checks ignore case, so "Alex" and "alex" count as the same username.

Already have an accounts table from an older version of this tutorial? Running schema.sql won't change a table that already exists, so read the fix for old accounts first.

3.1. Running schema.sql

Now let's run the file to create the database. Open MySQL Workbench and click the connection to your local server.

If there's no connection yet, click the + button next to MySQL Connections, give the connection any name and click OK. The default settings already point to the MySQL server on your computer.

Then click the new connection and enter your root password when Workbench asks for it.

If Workbench can't connect, MySQL probably isn't running or uses a different port. The fix for error 2003 shows how to start it. "Access denied" means the password is wrong, but on Ubuntu and Debian, follow the fix for error 1698 instead.

Once you're connected, choose File > Open SQL Script and pick schema.sql. Click the lightning bolt button to run it.

Then click the refresh button in the Schemas list on the left. You'll see the new pythonlogin database, with the accounts table inside it.

Prefer the command line? If the mysql client is on your PATH, you can run this command from the pythonlogin folder instead. Enter your root password when it asks:

Terminal
mysql -u root -p -e "source schema.sql"

Already have XAMPP from a PHP project? Its MariaDB works too. Start Apache and MySQL in the XAMPP Control Panel, open http://localhost/phpmyadmin/, paste the contents of schema.sql into the SQL tab and click Go.

4. Configuring the Flask App

The app reads its settings from a .env file and from the top of main.py.

The .env file keeps your secret key and your database username and password out of the code. Open .env and add:

Config .env
SECRET_KEY=replace-this-with-a-long-random-string
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_NAME=pythonlogin

Replace the placeholder secret key with a long random string. You can make one with our Flask secret key generator, or run this command in the terminal:

Terminal
python -c "import secrets; print(secrets.token_hex())"

Copy the line it prints and paste it after SECRET_KEY=, replacing the placeholder.

Next, put your MySQL root password after DB_PASSWORD=. If your root user has no password, leave it empty. If you created a separate MySQL user in the fix for error 1698, use that user's name and password instead.

The other values already match a default MySQL install and the database that schema.sql creates.

If you use Git, add .env to your .gitignore file, so the secret key never ends up in your repository.

4.1. Loading the Settings in main.py

The top of main.py loads these settings and turns on the security features.

To talk to MySQL, the app uses MySQL Connector/Python, Oracle's official driver. We picked it because it supports prepared statements, which send your SQL and the user's values to MySQL separately. You'll meet them in section 6.

Open main.py and add:

Python main.py
import os
import re
from functools import wraps

import mysql.connector
from dotenv import load_dotenv
from flask import (
    Flask, render_template, request, redirect, url_for, session, flash, g
)
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_wtf.csrf import CSRFProtect, CSRFError
from werkzeug.security import generate_password_hash, check_password_hash

# Load the settings in the .env file into environment variables
load_dotenv()

app = Flask(__name__)

# The secret key signs the session cookie, so keep it in .env, not the code
app.secret_key = os.environ.get('SECRET_KEY', '')
# Refuse to start without a real key, because anyone who knows the key
# could forge a login
if len(app.secret_key) < 32 or app.secret_key.startswith('replace-this'):
    raise SystemExit(
        'Set SECRET_KEY in the .env file to a long random string first!')

# Hide the session cookie from JavaScript, and don't send it when another
# website submits a form to this app
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'

# Reject form posts without a CSRF token, and keep each token valid for
# the whole session so a page left open still works
app.config['WTF_CSRF_TIME_LIMIT'] = None
csrf = CSRFProtect(app)

# Count requests per IP address for the login and registration limits
limiter = Limiter(get_remote_address, app=app, storage_uri='memory://')

# Your MySQL connection details, change them in the .env file
db_config = {
    'host': os.environ.get('DB_HOST', 'localhost'),
    'port': int(os.environ.get('DB_PORT', 3306)),
    'user': os.environ.get('DB_USER', 'root'),
    'password': os.environ.get('DB_PASSWORD', ''),
    'database': os.environ.get('DB_NAME', 'pythonlogin'),
    # Save every change straight away, so we don't need to call commit()
    'autocommit': True,
}

# Hash passwords with scrypt, using the cost settings OWASP recommends
PASSWORD_HASH_METHOD = 'scrypt:32768:8:3'

# Open at most one MySQL connection per request, only when a query needs it
def get_db():
    if 'db' not in g:
        g.db = mysql.connector.connect(**db_config)
    return g.db

# Close the MySQL connection when the request ends
@app.teardown_appcontext
def close_db(exception):
    db = g.pop('db', None)
    if db is not None:
        db.close()

# Stop browsers from caching private pages, and stop other websites from
# showing them in a frame
@app.after_request
def add_security_headers(response):
    response.headers['X-Frame-Options'] = 'DENY'
    if request.endpoint != 'static':
        response.headers['Cache-Control'] = 'no-store'
    return response

There's nothing to see in a browser yet, because the app has no pages. The comments explain each line, but three parts are worth a closer look.

The secret key. Flask uses the secret key to sign the session cookie. Anyone who knows the key could create a fake login, so the app refuses to start if the key is missing, shorter than 32 characters or still the placeholder.

The password hash settings. Werkzeug, a library that comes with Flask, hashes passwords with scrypt. Scrypt is deliberately slow and uses a lot of memory, so stolen hashes are hard to crack.

Werkzeug's default setting is scrypt:32768:8:1, where the first number sets how much memory each hash uses and the last number multiplies the work.

The OWASP Password Storage Cheat Sheet, a widely used security guide, asks for three times as much work at this memory size, so the app uses scrypt:32768:8:3 instead.

The database connection. get_db() opens a MySQL connection the first time a page needs one. It keeps the connection in g, where Flask stores values for the current request, so every query on that page shares it.

When the request ends, close_db() closes the connection.

You'll see the rate limiter again in section 7.2 and the Cache-Control header in section 11. The CSRF and cookie settings, which stop other websites from submitting forms to your app, are explained in section 13.

4.2. Checking Your Setup

Before you write any pages, you can check that everything loads. Run this command in the pythonlogin folder:

Terminal
flask --app main routes

Flask loads main.py without starting a server and prints a table of the app's routes. For now, the table only lists static, the route Flask adds for the files in your static folder, because you haven't added any pages yet.

This command doesn't connect to MySQL, so your database settings get tested later, when you try the forms. If you see an error instead of the table, look it up in Common Problems and How to Fix Them.

5. Adding the Stylesheet and Layout

Next, we'll add the files that control how every page looks.

The stylesheet and icons don't affect how the login works. Their code blocks are long, so they scroll. Copy each one into the file named in its header, or take the files from the free download.

CSS static/style.css
:root {
    --bg: #f3f5f8;
    --surface: #ffffff;
    --text: #1a2230;
    --text-muted: #566173;
    --placeholder: #667080;
    --accent: #2563eb;
    --accent-hover: #1d4ed8;
    --accent-soft: #e2efff;
    --accent-soft-text: #1e4bb8;
    --link: #2563eb;
    --focus: #2563eb;
    --field-bg: #f8fafc;
    --field-bg-focus: #ffffff;
    --field-border: #d8dee6;
    --field-border-hover: #b9c2ce;
    --focus-ring: rgb(37 99 235 / 0.16);
    --error-bg: #ffe4e8;
    --error-text: #be123c;
    --success-bg: #d9fbeb;
    --success-text: #0f734a;
    --shadow: 0 2px 6px rgb(26 34 48 / 0.05),
        0 16px 40px -16px rgb(26 34 48 / 0.2);
    --shadow-bar: 0 4px 20px rgb(26 34 48 / 0.07);
    --radius: 0.875rem;
    --radius-sm: 0.5rem;
    --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
        Arial, sans-serif;
    color-scheme: light;
}

@media (prefers-color-scheme: dark) {
    :root {
        --bg: #0e1319;
        --surface: #18202a;
        --text: #e6ebf2;
        --text-muted: #a1acbc;
        --placeholder: #8a95a7;
        --accent-soft: #1b3563;
        --accent-soft-text: #b8cbff;
        --link: #8db1ff;
        --focus: #8db1ff;
        --field-bg: #121922;
        --field-bg-focus: #121922;
        --field-border: #2c3747;
        --field-border-hover: #435064;
        --focus-ring: rgb(141 177 255 / 0.2);
        --error-bg: #3f1321;
        --error-text: #fda4af;
        --success-bg: #0b3326;
        --success-text: #6ee7b7;
        --shadow: 0 2px 6px rgb(0 0 0 / 0.25), 0 20px 48px -16px rgb(0 0 0 / 0.7);
        --shadow-bar: 0 4px 20px rgb(0 0 0 / 0.4);
        color-scheme: dark;
    }
}

*, *::before, *::after {
    box-sizing: border-box;
}

body {
    margin: 0;
    background-color: var(--bg);
    color: var(--text);
    font-family: var(--font);
    font-size: 1rem;
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
}

h1 {
    margin: 0 0 1.5rem;
    font-size: 1.5rem;
    line-height: 1.25;
    letter-spacing: -0.01em;
}

a {
    color: var(--link);
    text-underline-offset: 0.2em;
}

a:hover {
    text-decoration-thickness: 2px;
}

:focus-visible {
    outline: 2px solid var(--focus);
    outline-offset: 2px;
}

.icon {
    flex: none;
    width: 1.25rem;
    height: 1.25rem;
}

.auth {
    width: calc(100% - 2rem);
    max-width: 25rem;
    margin: clamp(1.5rem, 10vh, 6rem) auto 2rem;
    padding: 2rem;
    background-color: var(--surface);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
}

.auth-icon, .avatar {
    display: grid;
    place-items: center;
    flex: none;
    width: 3rem;
    height: 3rem;
    color: var(--accent-soft-text);
    background-color: var(--accent-soft);
    border-radius: var(--radius);
}

.auth-icon .icon {
    width: 1.5rem;
    height: 1.5rem;
}

.auth h1 {
    margin: 1.25rem 0 0.375rem;
}

.auth-intro {
    margin: 0 0 1.75rem;
    color: var(--text-muted);
}

.msg {
    display: flex;
    gap: 0.625rem;
    margin: 0 0 1.25rem;
    padding: 0.75rem 1rem;
    font-size: 0.9375rem;
    border-radius: var(--radius-sm);
}

.msg .icon {
    margin-top: 0.0625rem;
}

.msg-error {
    color: var(--error-text);
    background-color: var(--error-bg);
}

.msg-success {
    color: var(--success-text);
    background-color: var(--success-bg);
}

.field {
    margin-bottom: 1.25rem;
}

.field label {
    display: block;
    margin-bottom: 0.375rem;
    font-size: 0.875rem;
    font-weight: 600;
}

.input-wrap {
    position: relative;
}

.input-wrap > .icon {
    position: absolute;
    top: 50%;
    left: 0.875rem;
    color: var(--text-muted);
    transform: translateY(-50%);
    pointer-events: none;
}

.input-wrap input {
    width: 100%;
    height: 2.75rem;
    padding: 0 0.875rem 0 2.75rem;
    font: inherit;
    color: var(--text);
    background-color: var(--field-bg);
    border: 1px solid var(--field-border);
    border-radius: 0.625rem;
    box-shadow: 0 1px 2px rgb(16 24 40 / 0.04);
    transition: border-color 0.15s, box-shadow 0.15s, background-color 0.15s;
}

.input-wrap input::placeholder {
    color: var(--placeholder);
    opacity: 1;
}

.input-wrap input:hover {
    border-color: var(--field-border-hover);
}

.input-wrap input:focus-visible {
    background-color: var(--field-bg-focus);
    border-color: var(--focus);
    outline: 2px solid transparent;
    box-shadow: 0 0 0 4px var(--focus-ring);
}

.input-wrap .password-input {
    padding-right: 3rem;
}

.field-hint {
    margin: 0.375rem 0 0;
    font-size: 0.875rem;
    color: var(--text-muted);
}

.password-toggle {
    position: absolute;
    top: 50%;
    right: 0.25rem;
    display: grid;
    place-items: center;
    width: 2.25rem;
    height: 2.25rem;
    padding: 0;
    color: var(--text-muted);
    background: none;
    border: 0;
    border-radius: 0.375rem;
    transform: translateY(-50%);
    cursor: pointer;
}

.password-toggle:hover {
    color: var(--accent-soft-text);
    background-color: var(--accent-soft);
}

.password-toggle[hidden],
.password-toggle[aria-pressed="true"] .icon-show,
.password-toggle[aria-pressed="false"] .icon-hide {
    display: none;
}

.btn {
    width: 100%;
    height: 2.75rem;
    margin-top: 0.5rem;
    font: inherit;
    font-weight: 600;
    color: #ffffff;
    background-color: var(--accent);
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    cursor: pointer;
    transition: background-color 0.15s;
}

.btn:hover {
    background-color: var(--accent-hover);
}

.auth-switch {
    margin: 1.5rem 0 0;
    font-size: 0.9375rem;
    text-align: center;
    color: var(--text-muted);
}

.navbar {
    background-color: var(--surface);
    box-shadow: var(--shadow-bar);
}

.navbar-inner {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem 1.5rem;
    max-width: 60rem;
    min-height: 4rem;
    margin: 0 auto;
    padding: 0.5rem 1.5rem;
}

.navbar-title {
    margin-right: auto;
    font-size: 1.0625rem;
    font-weight: 700;
    color: var(--text);
    text-decoration: none;
}

.navbar-links {
    display: flex;
    align-items: center;
    gap: 0.25rem;
}

.navbar-links form {
    margin: 0;
}

.navbar-links a, .navbar-logout {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    height: 2.5rem;
    padding: 0 0.75rem;
    font: inherit;
    font-size: 0.9375rem;
    font-weight: 500;
    color: var(--text-muted);
    text-decoration: none;
    background: none;
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    cursor: pointer;
}

.navbar-links .icon {
    width: 1.125rem;
    height: 1.125rem;
}

.navbar-links a:hover, .navbar-logout:hover {
    color: var(--text);
    background-color: var(--bg);
}

.navbar-links a[aria-current="page"] {
    font-weight: 600;
    color: var(--accent-soft-text);
    background-color: var(--accent-soft);
}

.page {
    max-width: 60rem;
    margin: 0 auto;
    padding: 2.5rem 1.5rem;
}

.card {
    padding: 1.75rem;
    background-color: var(--surface);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
}

.avatar {
    width: 3.5rem;
    height: 3.5rem;
    font-size: 1.5rem;
    font-weight: 700;
    border-radius: 1rem;
}

.welcome, .profile {
    display: flex;
    align-items: flex-start;
    gap: 1.25rem;
}

.welcome-title {
    margin: 0.25rem 0 0;
    font-size: 1.25rem;
    font-weight: 700;
}

.welcome-text {
    margin: 0.25rem 0 0;
    color: var(--text-muted);
}

.details {
    flex: 1;
    min-width: 0;
    margin: 0;
}

.details div {
    display: grid;
    grid-template-columns: 9rem 1fr;
    gap: 1rem;
    padding: 0.5rem 0;
}

.details dt {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.875rem;
    font-weight: 600;
    color: var(--text-muted);
}

.details dt .icon {
    width: 1.125rem;
    height: 1.125rem;
}

.details dd {
    margin: 0;
    overflow-wrap: anywhere;
}

@media (max-width: 30rem) {
    .auth {
        padding: 1.75rem 1.25rem 1.5rem;
    }
    .navbar-inner, .page {
        padding-left: 1rem;
        padding-right: 1rem;
    }
    .navbar-links a, .navbar-logout {
        padding: 0 0.625rem;
    }
    .profile {
        flex-direction: column;
    }
    .details div {
        grid-template-columns: 1fr;
        gap: 0.125rem;
    }
}

@media (pointer: coarse) {
    .input-wrap input, .btn {
        height: 3rem;
    }
    .password-toggle {
        width: 2.75rem;
        height: 2.75rem;
        right: 0.125rem;
    }
    .navbar-links a, .navbar-logout {
        height: 2.75rem;
    }
}

@media (prefers-reduced-motion: reduce) {
    .btn, .input-wrap input {
        transition: none;
    }
}

The stylesheet stores its colors and shadows in CSS variables, so you can restyle the whole app by changing a few values. A second set of variables switches the colors for dark mode.

XML static/icons.svg
<svg xmlns="http://www.w3.org/2000/svg">
    <!-- Heroicons v2.2.0 by Tailwind Labs, Inc., MIT License: https://github.com/tailwindlabs/heroicons/blob/master/LICENSE -->
    <symbol id="user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
    </symbol>
    <symbol id="mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/>
    </symbol>
    <symbol id="lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
    </symbol>
    <symbol id="eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
        <path d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
    </symbol>
    <symbol id="eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"/>
    </symbol>
    <symbol id="log-in" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15M12 9l3 3m0 0-3 3m3-3H2.25"/>
    </symbol>
    <symbol id="user-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z"/>
    </symbol>
    <symbol id="log-out" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"/>
    </symbol>
    <symbol id="home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/>
    </symbol>
    <symbol id="user-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
    </symbol>
    <symbol id="calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"/>
    </symbol>
    <symbol id="alert-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/>
    </symbol>
    <symbol id="check-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
    </symbol>
</svg>

These outline icons come from Heroicons. Each icon has an id, such as lock, and the templates point to it as icons.svg#lock. They're MIT-licensed, so you can use and share them as long as you keep the license comment at the top.

Open static/favicon.svg and add the icon that appears in the browser tab:

XML static/favicon.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
    <!-- Heroicons v2.2.0 by Tailwind Labs, Inc., MIT License -->
    <path d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
</svg>

Open static/script.js and add the code for the show-password button:

JS static/script.js
// Show or hide the password when the user clicks the eye button
document.querySelectorAll('.password-toggle').forEach(button => {
    const input = document.getElementById(button.getAttribute('aria-controls'));
    // The button needs JavaScript to work, so it stays hidden until this
    // script runs
    button.hidden = false;
    button.addEventListener('click', () => {
        const isVisible = input.type === 'text';
        // Switch the input type and tell screen readers whether the
        // password is showing
        input.type = isVisible ? 'password' : 'text';
        button.setAttribute('aria-pressed', !isVisible);
    });
});

The forms you'll build next have an eye button that shows or hides the password. The button stays hidden until this script runs, and aria-pressed tells screen readers whether the password is showing.

5.1. Building the Layout Template

Every page in the app shares one layout file. Open templates/layout.html and add:

HTML templates/layout.html
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>{% block title %}{% endblock %}</title>
        <link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
        <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
        <script src="{{ url_for('static', filename='script.js') }}" defer></script>
    </head>
    <body>
        {% if 'loggedin' in session %}
        <header class="navbar">
            <div class="navbar-inner">
                <a href="{{ url_for('home') }}" class="navbar-title">Website Title</a>
                <nav class="navbar-links" aria-label="Main">
                    <a href="{{ url_for('home') }}"{% if request.endpoint == 'home' %} aria-current="page"{% endif %}>
                        <svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#home"></use></svg>
                        Home
                    </a>
                    <a href="{{ url_for('profile') }}"{% if request.endpoint == 'profile' %} aria-current="page"{% endif %}>
                        <svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#user-circle"></use></svg>
                        Profile
                    </a>
                    <form action="{{ url_for('logout') }}" method="post">
                        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
                        <button type="submit" class="navbar-logout">
                            <svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#log-out"></use></svg>
                            Log out
                        </button>
                    </form>
                </nav>
            </div>
        </header>
        {% endif %}
        <main>
            {% block content %}{% endblock %}
        </main>
    </body>
</html>

Replace Website Title with your site's name.

Each page template starts with {% extends 'layout.html' %} and then fills in its own title and content blocks. That way, the page head and the navigation bar live in a single file.

Jinja, Flask's template language, has two kinds of tags. {{ }} prints a value. It also escapes any HTML inside the value, so text that a user typed can't add tags or scripts to the page.

{% %} runs logic, such as the if statement that shows the navigation bar only to logged-in visitors.

url_for('home') builds a link to a route from its function name. With 'static', it builds a link to a file in the static folder instead. Either way, your links keep working if a URL changes.

The logout button in the navigation bar is a small form that sends a POST request. Logging Users Out explains why.

6. Building the Registration System

We'll build registration first, so you'll have an account to test the login with.

The registration and login pages link to each other, so neither page will load until both routes exist. You'll try them both in Trying the Forms, at the end of section 7.

6.1. Writing the Registration Template

Open templates/register.html and add:

HTML templates/register.html
{% extends 'layout.html' %}

{% block title %}Register{% endblock %}

{% block content %}
<div class="auth">
    <span class="auth-icon"><svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#user-plus"></use></svg></span>
    <h1>Create an account</h1>
    <p class="auth-intro">Choose a username, then add your email and a password.</p>
    {% if msg %}
    <p class="msg msg-error" role="alert">
        <svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#alert-circle"></use></svg>
        {{ msg }}
    </p>
    {% endif %}
    <form action="{{ url_for('register') }}" method="post">
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <div class="field">
            <label for="username">Username</label>
            <div class="input-wrap">
                <svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#user"></use></svg>
                <input type="text" name="username" id="username"
                    placeholder="Choose a username"
                    value="{{ request.form.get('username', '') }}"
                    autocomplete="username" autocapitalize="none"
                    spellcheck="false" minlength="3" maxlength="20"
                    pattern="[A-Za-z0-9]+" aria-describedby="username-hint"
                    required>
            </div>
            <p class="field-hint" id="username-hint">3 to 20 letters and numbers.</p>
        </div>
        <div class="field">
            <label for="email">Email</label>
            <div class="input-wrap">
                <svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#mail"></use></svg>
                <input type="email" name="email" id="email"
                    placeholder="you@example.com"
                    value="{{ request.form.get('email', '') }}"
                    autocomplete="email" maxlength="100" required>
            </div>
        </div>
        <div class="field">
            <label for="password">Password</label>
            <div class="input-wrap">
                <svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#lock"></use></svg>
                <input type="password" name="password" id="password"
                    class="password-input" placeholder="Create a password"
                    autocomplete="new-password" minlength="8" maxlength="128"
                    aria-describedby="password-hint" required>
                <button type="button" class="password-toggle"
                    aria-controls="password" aria-label="Show password"
                    aria-pressed="false" hidden>
                    <svg class="icon icon-show" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#eye"></use></svg>
                    <svg class="icon icon-hide" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#eye-off"></use></svg>
                </button>
            </div>
            <p class="field-hint" id="password-hint">At least 8 characters.</p>
        </div>
        <button type="submit" class="btn">Create account</button>
    </form>
    <p class="auth-switch">Already have an account? <a href="{{ url_for('login') }}">Log in</a></p>
</div>
{% endblock %}

The browser checks the field lengths and the username pattern before it sends the form. The autocomplete="new-password" setting lets password managers suggest a strong password.

The hidden csrf_token field holds a secret value that only your own pages know. If another website tried to submit a fake form to /register, it wouldn't have this value, so the app would reject it.

6.2. Adding the Register Route

Add the account_exists() function and the register route to the end of main.py:

Python main.py
# Check whether an account already uses this username or email
def account_exists(username, email):
    with get_db().cursor(prepared=True) as cursor:
        cursor.execute('''
            SELECT id FROM accounts
            WHERE username = %s OR email = %s LIMIT 1
        ''', (username, email))
        # None means no account matched
        return cursor.fetchone() is not None

# http://localhost:5000/register - shows and handles the registration form
@app.route('/register', methods=['GET', 'POST'])
@limiter.limit('5 per minute', methods=['POST'])
def register():
    # Logged-in users don't need to register, so send them to the home page
    if 'loggedin' in session:
        return redirect(url_for('home'))
    # The error message to show, if any
    msg = ''
    # Handle the form when it's submitted with all three fields
    if (request.method == 'POST' and 'username' in request.form
            and 'password' in request.form and 'email' in request.form):
        # Read the fields, trimming spaces around the username and email
        username = request.form['username'].strip()
        password = request.form['password']
        email = request.form['email'].strip()
        # Validate the form data before we touch the database
        if not re.fullmatch(r'[A-Za-z0-9]{3,20}', username):
            msg = 'Username must be 3 to 20 letters and numbers!'
        elif (len(email) > 100
                or not re.fullmatch(r'[^@\s]+@[^@\s]+\.[^@\s]+', email)):
            msg = 'Please enter a valid email address!'
        elif not 8 <= len(password) <= 128:
            msg = 'Password must be 8 to 128 characters long!'
        elif account_exists(username, email):
            msg = 'An account with that username or email already exists!'
        else:
            # Hash the password, so the password itself is never stored
            hashed_password = generate_password_hash(
                password, method=PASSWORD_HASH_METHOD)
            try:
                # Insert the new account into the accounts table
                with get_db().cursor(prepared=True) as cursor:
                    cursor.execute('''
                        INSERT INTO accounts (username, password, email)
                        VALUES (%s, %s, %s)
                    ''', (username, hashed_password, email))
            except mysql.connector.IntegrityError:
                # Another request took the username or email a moment ago,
                # for example after a double-click
                msg = 'An account with that username or email already exists!'
            else:
                # Registration worked, so show a message on the login page
                flash('You have successfully registered! You can now log in.')
                return redirect(url_for('login'))
    elif request.method == 'POST':
        # A POST with a valid token but missing fields, such as an edited form
        msg = 'Please fill out the form!'
    # Show the registration form, with the message if there is one
    return render_template('register.html', msg=msg)

When someone opens /register, the browser sends a GET request, and the route shows the empty form. When they submit the form, the browser sends a POST request with the fields in request.form.

The route checks every field again on the server, because anyone can get around the checks in the browser.

account_exists() looks for an account with the same username or email, using a prepared statement. Each %s in the SQL is a placeholder, not Python string formatting, and the values go in the tuple after the SQL.

With cursor(prepared=True), the SQL and the values travel to MySQL separately, so a typed value can never change the query. The with block closes the cursor when the query is done.

The triple quotes let the SQL span several lines.

If the username matches one account and the email matches another, the query finds two rows. LIMIT 1 keeps only the first, because the connector raises an "Unread result found" error when the code leaves a row unread.

6.3. Hashing the Password and Saving the Account

Once the form passes every check, the route hashes the password and saves the account. generate_password_hash() turns the password into a hash before it's saved. It also adds a random salt, so two people with the same password still get different hashes.

If two sign-ups with the same username or email arrive at the same moment, for example when someone double-clicks the button, the unique keys make MySQL reject the second one. The except block then shows the "already exists" message.

The else block after try runs only when nothing went wrong, which means the account was saved.

Finally, flash() stores a one-time message for the login page, and the route redirects there. Redirecting after a POST stops the browser from submitting the form twice if the user refreshes the page.

localhost:5000/register
Flask registration form showing an account already exists error
The registration page after someone tries a username that's already taken.

In the database, the password column stores a hash such as scrypt:32768:8:3$salt$hash, never the password itself. Our scrypt hash generator shows how the settings change the hash.

Run flask --app main routes again. You'll see register in the list now.

7. Building the Login System

The login page checks the username and password, and then logs the user in.

7.1. Writing the Login Template

The login page lives at the site's main address, so it uses the index.html template. Open templates/index.html and add:

HTML templates/index.html
{% extends 'layout.html' %}

{% block title %}Log in{% endblock %}

{% block content %}
<div class="auth">
    <span class="auth-icon"><svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#log-in"></use></svg></span>
    <h1>Log in</h1>
    <p class="auth-intro">Welcome back. Enter your details to continue.</p>
    {% for message in get_flashed_messages() %}
    <p class="msg msg-success" role="status">
        <svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#check-circle"></use></svg>
        {{ message }}
    </p>
    {% endfor %}
    {% if msg %}
    <p class="msg msg-error" role="alert">
        <svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#alert-circle"></use></svg>
        {{ msg }}
    </p>
    {% endif %}
    <form action="{{ url_for('login') }}" method="post">
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <div class="field">
            <label for="username">Username</label>
            <div class="input-wrap">
                <svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#user"></use></svg>
                <input type="text" name="username" id="username"
                    placeholder="Enter your username"
                    value="{{ request.form.get('username', '') }}"
                    autocomplete="username" autocapitalize="none"
                    spellcheck="false" required>
            </div>
        </div>
        <div class="field">
            <label for="password">Password</label>
            <div class="input-wrap">
                <svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#lock"></use></svg>
                <input type="password" name="password" id="password"
                    class="password-input" placeholder="Enter your password"
                    autocomplete="current-password" required>
                <button type="button" class="password-toggle"
                    aria-controls="password" aria-label="Show password"
                    aria-pressed="false" hidden>
                    <svg class="icon icon-show" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#eye"></use></svg>
                    <svg class="icon icon-hide" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#eye-off"></use></svg>
                </button>
            </div>
        </div>
        <button type="submit" class="btn">Log in</button>
    </form>
    <p class="auth-switch">Don't have an account? <a href="{{ url_for('register') }}">Register</a></p>
</div>
{% endblock %}

The green message shows text saved with flash(), such as the note that you've registered. The red message shows the msg variable when a login fails.

7.2. Checking Passwords and Starting the Session

Add the login route and the form_error() function to the end of main.py:

Python main.py
# http://localhost:5000/ - shows and handles the login form
@app.route('/', methods=['GET', 'POST'])
@limiter.limit('5 per minute', methods=['POST'])
def login():
    # Logged-in users are sent straight to the home page
    if 'loggedin' in session:
        return redirect(url_for('home'))
    # The error message to show, if any
    msg = ''
    # Handle the form when it's submitted with both fields
    if (request.method == 'POST' and 'username' in request.form
            and 'password' in request.form):
        # Read the fields, trimming spaces around the username
        username = request.form['username'].strip()
        password = request.form['password']
        # Fetch the account by username with a prepared statement
        with get_db().cursor(prepared=True, dictionary=True) as cursor:
            cursor.execute('''
                SELECT id, username, password FROM accounts WHERE username = %s
            ''', (username,))
            # fetchone() returns a dictionary, or None if no account matched
            account = cursor.fetchone()
        # Check that the account exists and the password matches its hash
        if account and check_password_hash(account['password'], password):
            # Clear any old session data before we log the user in
            session.clear()
            # Store the user in the session, which every route can read
            session['loggedin'] = True
            session['id'] = account['id']
            session['username'] = account['username']
            # Redirect to the home page
            return redirect(url_for('home'))
        # The same message for a wrong username or a wrong password
        msg = 'Incorrect username or password!'
    # Show the login form, with the message if there is one
    return render_template('index.html', msg=msg)

# Show the form again after too many attempts or an expired CSRF token
@app.errorhandler(429)
@app.errorhandler(CSRFError)
def form_error(error):
    # Work out which form the visitor was using
    page = 'register' if request.endpoint == 'register' else 'login'
    # Posts from other websites arrive without the session cookie. Showing
    # the form would start a new session and log the user out, so redirect
    if not session:
        return redirect(url_for(page))
    # Logged-in users don't need the login form, so send them home
    if 'loggedin' in session:
        return redirect(url_for('home'))
    if error.code == 429:
        msg = 'Too many attempts! Please wait a minute and try again.'
    else:
        msg = 'The form has expired! Please try again.'
    # Show whichever form the visitor was using
    template = 'register.html' if page == 'register' else 'index.html'
    return render_template(template, msg=msg), error.code

The route looks up the account by username with a prepared statement, so a username such as ' OR '1'='1 stays plain text. If you built the query with an f-string instead, that username would become part of the SQL and match every account.

The dictionary=True option returns the account as a Python dictionary, so the code can read values like account['password'].

The app can't search MySQL for the password itself, because every hash has its own random salt. Instead, check_password_hash() hashes the typed password with the stored salt and settings, and compares the two hashes.

When the password is right, session.clear() removes anything left over from an earlier visit. The route then saves loggedin, id and username in the session, so the other pages know who's logged in.

The same message appears for a wrong username and a wrong password, so the login form never reveals which usernames exist. The registration form does have to say when a name is taken, but its rate limit slows down anyone who tries lots of names.

The @limiter.limit lines let each IP address try to log in five times per minute, and register five times per minute. This slows down anyone guessing passwords.

After five attempts in a minute, form_error() shows the form again with a "Too many attempts!" message. It also handles a missing or expired CSRF token, and section 13 explains why it sometimes redirects instead.

Did you know?Each hash stores its own method and salt, so you can raise the numbers in PASSWORD_HASH_METHOD later, and older hashes will still verify.

7.3. Trying the Forms

Both forms are ready, so it's time to try them in a browser.

Start the development server from the pythonlogin folder, with the virtual environment active:

Terminal
flask --app main run --debug

The terminal shows a warning that this is a development server, which is fine for testing. Then it shows Running on http://127.0.0.1:5000. Keep the terminal open while you test.

The --debug option restarts the server whenever you save a file. It also turns on a debugger that can run Python code, so never use it on a live site. Running python main.py won't start the app, so always use the flask command.

Open http://localhost:5000/register in your browser and create an account. You'll be sent to the login page, with a green message saying that you've registered.

localhost:5000
Flask login form with a success message after registering
The login page after registering, with the message from flash().

Go back to the registration page and try the same username again. This time, you'll see the "already exists" error.

On the login page, log in with a wrong password to see the error message.

Now look at the database. In MySQL Workbench, expand pythonlogin and then Tables, right-click accounts and choose Select Rows. The password column starts with scrypt:32768:8:3, not the password you typed.

If you use XAMPP, open the accounts table in phpMyAdmin instead.

Finally, log in with the right password. You'll see a BuildError page, which is expected at this point. It means the login worked and the app tried to send you to /home, a page you'll build in the next section.

Press Ctrl+C in the terminal to stop the server for now.

8. Protecting Pages with a login_required Decorator

A decorator called login_required will keep the home page private.

Add the decorator and the home route to the end of main.py:

Python main.py
# Add @login_required to any page that only logged-in users can see
def login_required(view):
    @wraps(view)
    def wrapped_view(*args, **kwargs):
        # Redirect visitors who aren't logged in to the login page
        if 'loggedin' not in session:
            return redirect(url_for('login'))
        return view(*args, **kwargs)
    return wrapped_view

# http://localhost:5000/home - the home page, for logged-in users only
@app.route('/home')
@login_required
def home():
    # Show the home page with the username stored in the session
    return render_template('home.html', username=session['username'])

A decorator wraps a function in another function that runs first. Here, login_required checks the session before the page loads. If the visitor isn't logged in, it sends them to the login page instead.

@wraps(view) comes from the functools import at the top of main.py. It keeps each route's own function name. Without it, every protected route would be named wrapped_view, and Flask would refuse to start.

To protect any page you add later, put @login_required under its @app.route line. Flask-Login, a popular extension, has its own login_required decorator that works in a similar way.

Open templates/home.html and add:

HTML templates/home.html
{% extends 'layout.html' %}

{% block title %}Home{% endblock %}

{% block content %}
<div class="page">
    <h1>Home</h1>
    <div class="card welcome">
        <span class="avatar" aria-hidden="true">{{ username[0]|upper }}</span>
        <div>
            <p class="welcome-title">Welcome back, {{ username }}!</p>
            <p class="welcome-text">
                You're logged in. Only logged-in users can see this page.
            </p>
        </div>
    </div>
</div>
{% endblock %}

The home page won't load yet, because its navigation bar links to the profile and logout routes. You'll add those in the next two sections. Once they're in place, the home page will look like this:

localhost:5000/home
Flask home page welcoming the logged-in user
The home page, which only logged-in users can reach.

9. Showing the Profile Page

The profile page shows the logged-in user's account details from MySQL.

Add the profile route to the end of main.py:

Python main.py
# http://localhost:5000/profile - the profile page, for logged-in users only
@app.route('/profile')
@login_required
def profile():
    # Fetch the account by the ID stored in the session
    with get_db().cursor(prepared=True, dictionary=True) as cursor:
        cursor.execute('''
            SELECT username, email, registered FROM accounts WHERE id = %s
        ''', (session['id'],))
        account = cursor.fetchone()
    # If the account no longer exists, log the user out
    if account is None:
        session.clear()
        return redirect(url_for('login'))
    # Show the profile page with the account details
    return render_template('profile.html', account=account)

The route looks up the account by the ID saved in the session. If the account no longer exists, it logs the user out.

Open templates/profile.html and add:

HTML templates/profile.html
{% extends 'layout.html' %}

{% block title %}Profile{% endblock %}

{% block content %}
<div class="page">
    <h1>Profile</h1>
    <div class="card profile">
        <span class="avatar" aria-hidden="true">
            {{ account['username'][0]|upper }}
        </span>
        <dl class="details">
            <div>
                <dt><svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#user"></use></svg>Username</dt>
                <dd>{{ account['username'] }}</dd>
            </div>
            <div>
                <dt><svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#mail"></use></svg>Email</dt>
                <dd>{{ account['email'] }}</dd>
            </div>
            <div>
                <dt><svg class="icon" aria-hidden="true"><use href="{{ url_for('static', filename='icons.svg') }}#calendar"></use></svg>Registered</dt>
                <dd>
                    {{ account['registered'].strftime('%B') }}
                    {{ account['registered'].day }},
                    {{ account['registered'].year }}
                </dd>
            </div>
        </dl>
    </div>
</div>
{% endblock %}

The template reads each value from the account dictionary, such as account['email']. It also formats the registration date, which MySQL returns as a Python datetime.

localhost:5000/profile
Flask profile page listing the username, email and registration date
The profile page with the details stored in MySQL.

10. Logging Users Out

Logging out clears the session and sends the user back to the login page.

Add the last route to the end of main.py:

Python main.py
# http://localhost:5000/logout - POST only, so other websites can't log
# your users out with a link
@app.route('/logout', methods=['POST'])
def logout():
    # Remove all session data, which logs the user out
    session.clear()
    # Let the user know it worked
    flash('You have been logged out.')
    # Redirect to the login page
    return redirect(url_for('login'))

The logout route only accepts POST requests. If it accepted GET requests, any website could log your users out with a simple link. A POST request needs the CSRF token, which only your own pages have.

11. Running and Testing the App

Every route is now in place, so the whole app is ready to run.

Start the server again from the pythonlogin folder:

Terminal
flask --app main run --debug

Open http://localhost:5000 and log in with the account you created while trying the forms. Once you're logged in, you'll land on the home page and see your username displayed.

Next, test the security features:

  • A private page. Log out, then open http://localhost:5000/home. Because you're logged out, you'll be sent to the login page.
  • The Back button. Log in again, open your profile page, then log out and press the browser's Back button. You'll see the login page, because the app tells the browser not to keep copies of private pages.
  • SQL injection. Log in with ' OR '1'='1 as the username and any password. You'll get the usual "Incorrect username or password!" message, because the prepared statement treats it as plain text.
  • The rate limit. Enter a wrong password several times in quick succession. Your earlier attempts count too, so after five login attempts in a minute you'll see "Too many attempts!" The form works again within a minute.

If something behaves differently, compare your main.py with the complete file:

Python main.py
import os
import re
from functools import wraps

import mysql.connector
from dotenv import load_dotenv
from flask import (
    Flask, render_template, request, redirect, url_for, session, flash, g
)
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_wtf.csrf import CSRFProtect, CSRFError
from werkzeug.security import generate_password_hash, check_password_hash

# Load the settings in the .env file into environment variables
load_dotenv()

app = Flask(__name__)

# The secret key signs the session cookie, so keep it in .env, not the code
app.secret_key = os.environ.get('SECRET_KEY', '')
# Refuse to start without a real key, because anyone who knows the key
# could forge a login
if len(app.secret_key) < 32 or app.secret_key.startswith('replace-this'):
    raise SystemExit(
        'Set SECRET_KEY in the .env file to a long random string first!')

# Hide the session cookie from JavaScript, and don't send it when another
# website submits a form to this app
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'

# Reject form posts without a CSRF token, and keep each token valid for
# the whole session so a page left open still works
app.config['WTF_CSRF_TIME_LIMIT'] = None
csrf = CSRFProtect(app)

# Count requests per IP address for the login and registration limits
limiter = Limiter(get_remote_address, app=app, storage_uri='memory://')

# Your MySQL connection details, change them in the .env file
db_config = {
    'host': os.environ.get('DB_HOST', 'localhost'),
    'port': int(os.environ.get('DB_PORT', 3306)),
    'user': os.environ.get('DB_USER', 'root'),
    'password': os.environ.get('DB_PASSWORD', ''),
    'database': os.environ.get('DB_NAME', 'pythonlogin'),
    # Save every change straight away, so we don't need to call commit()
    'autocommit': True,
}

# Hash passwords with scrypt, using the cost settings OWASP recommends
PASSWORD_HASH_METHOD = 'scrypt:32768:8:3'

# Open at most one MySQL connection per request, only when a query needs it
def get_db():
    if 'db' not in g:
        g.db = mysql.connector.connect(**db_config)
    return g.db

# Close the MySQL connection when the request ends
@app.teardown_appcontext
def close_db(exception):
    db = g.pop('db', None)
    if db is not None:
        db.close()

# Stop browsers from caching private pages, and stop other websites from
# showing them in a frame
@app.after_request
def add_security_headers(response):
    response.headers['X-Frame-Options'] = 'DENY'
    if request.endpoint != 'static':
        response.headers['Cache-Control'] = 'no-store'
    return response

# Check whether an account already uses this username or email
def account_exists(username, email):
    with get_db().cursor(prepared=True) as cursor:
        cursor.execute('''
            SELECT id FROM accounts
            WHERE username = %s OR email = %s LIMIT 1
        ''', (username, email))
        # None means no account matched
        return cursor.fetchone() is not None

# http://localhost:5000/register - shows and handles the registration form
@app.route('/register', methods=['GET', 'POST'])
@limiter.limit('5 per minute', methods=['POST'])
def register():
    # Logged-in users don't need to register, so send them to the home page
    if 'loggedin' in session:
        return redirect(url_for('home'))
    # The error message to show, if any
    msg = ''
    # Handle the form when it's submitted with all three fields
    if (request.method == 'POST' and 'username' in request.form
            and 'password' in request.form and 'email' in request.form):
        # Read the fields, trimming spaces around the username and email
        username = request.form['username'].strip()
        password = request.form['password']
        email = request.form['email'].strip()
        # Validate the form data before we touch the database
        if not re.fullmatch(r'[A-Za-z0-9]{3,20}', username):
            msg = 'Username must be 3 to 20 letters and numbers!'
        elif (len(email) > 100
                or not re.fullmatch(r'[^@\s]+@[^@\s]+\.[^@\s]+', email)):
            msg = 'Please enter a valid email address!'
        elif not 8 <= len(password) <= 128:
            msg = 'Password must be 8 to 128 characters long!'
        elif account_exists(username, email):
            msg = 'An account with that username or email already exists!'
        else:
            # Hash the password, so the password itself is never stored
            hashed_password = generate_password_hash(
                password, method=PASSWORD_HASH_METHOD)
            try:
                # Insert the new account into the accounts table
                with get_db().cursor(prepared=True) as cursor:
                    cursor.execute('''
                        INSERT INTO accounts (username, password, email)
                        VALUES (%s, %s, %s)
                    ''', (username, hashed_password, email))
            except mysql.connector.IntegrityError:
                # Another request took the username or email a moment ago,
                # for example after a double-click
                msg = 'An account with that username or email already exists!'
            else:
                # Registration worked, so show a message on the login page
                flash('You have successfully registered! You can now log in.')
                return redirect(url_for('login'))
    elif request.method == 'POST':
        # A POST with a valid token but missing fields, such as an edited form
        msg = 'Please fill out the form!'
    # Show the registration form, with the message if there is one
    return render_template('register.html', msg=msg)

# http://localhost:5000/ - shows and handles the login form
@app.route('/', methods=['GET', 'POST'])
@limiter.limit('5 per minute', methods=['POST'])
def login():
    # Logged-in users are sent straight to the home page
    if 'loggedin' in session:
        return redirect(url_for('home'))
    # The error message to show, if any
    msg = ''
    # Handle the form when it's submitted with both fields
    if (request.method == 'POST' and 'username' in request.form
            and 'password' in request.form):
        # Read the fields, trimming spaces around the username
        username = request.form['username'].strip()
        password = request.form['password']
        # Fetch the account by username with a prepared statement
        with get_db().cursor(prepared=True, dictionary=True) as cursor:
            cursor.execute('''
                SELECT id, username, password FROM accounts WHERE username = %s
            ''', (username,))
            # fetchone() returns a dictionary, or None if no account matched
            account = cursor.fetchone()
        # Check that the account exists and the password matches its hash
        if account and check_password_hash(account['password'], password):
            # Clear any old session data before we log the user in
            session.clear()
            # Store the user in the session, which every route can read
            session['loggedin'] = True
            session['id'] = account['id']
            session['username'] = account['username']
            # Redirect to the home page
            return redirect(url_for('home'))
        # The same message for a wrong username or a wrong password
        msg = 'Incorrect username or password!'
    # Show the login form, with the message if there is one
    return render_template('index.html', msg=msg)

# Show the form again after too many attempts or an expired CSRF token
@app.errorhandler(429)
@app.errorhandler(CSRFError)
def form_error(error):
    # Work out which form the visitor was using
    page = 'register' if request.endpoint == 'register' else 'login'
    # Posts from other websites arrive without the session cookie. Showing
    # the form would start a new session and log the user out, so redirect
    if not session:
        return redirect(url_for(page))
    # Logged-in users don't need the login form, so send them home
    if 'loggedin' in session:
        return redirect(url_for('home'))
    if error.code == 429:
        msg = 'Too many attempts! Please wait a minute and try again.'
    else:
        msg = 'The form has expired! Please try again.'
    # Show whichever form the visitor was using
    template = 'register.html' if page == 'register' else 'index.html'
    return render_template(template, msg=msg), error.code

# Add @login_required to any page that only logged-in users can see
def login_required(view):
    @wraps(view)
    def wrapped_view(*args, **kwargs):
        # Redirect visitors who aren't logged in to the login page
        if 'loggedin' not in session:
            return redirect(url_for('login'))
        return view(*args, **kwargs)
    return wrapped_view

# http://localhost:5000/home - the home page, for logged-in users only
@app.route('/home')
@login_required
def home():
    # Show the home page with the username stored in the session
    return render_template('home.html', username=session['username'])

# http://localhost:5000/profile - the profile page, for logged-in users only
@app.route('/profile')
@login_required
def profile():
    # Fetch the account by the ID stored in the session
    with get_db().cursor(prepared=True, dictionary=True) as cursor:
        cursor.execute('''
            SELECT username, email, registered FROM accounts WHERE id = %s
        ''', (session['id'],))
        account = cursor.fetchone()
    # If the account no longer exists, log the user out
    if account is None:
        session.clear()
        return redirect(url_for('login'))
    # Show the profile page with the account details
    return render_template('profile.html', account=account)

# http://localhost:5000/logout - POST only, so other websites can't log
# your users out with a link
@app.route('/logout', methods=['POST'])
def logout():
    # Remove all session data, which logs the user out
    session.clear()
    # Let the user know it worked
    flash('You have been logged out.')
    # Redirect to the login page
    return redirect(url_for('login'))

12. Login Security Best Practices for Flask

Here are six habits that keep a Flask login system safe.

Do this

  • Hash passwords with scrypt
  • Use prepared statements
  • Put a CSRF token in every form
  • Limit login attempts
  • Log out with a POST request
  • Keep the secret key in .env

Not this

  • Store plain-text or SHA-1 passwords
  • Build SQL with f-strings
  • Trust browser validation alone
  • Say which login detail was wrong
  • Log out with a GET link
  • Commit the secret key to Git

Before real people use your app, make these six changes:

  • Use HTTPS. Serve the site over HTTPS, and set SESSION_COOKIE_SECURE to True so the cookie only travels over secure connections.
  • Use a production server. Run the app with a production server such as Gunicorn on Linux or Waitress on Windows, and turn debug mode off.
  • Share the rate limits. Install Flask-Limiter[redis] and point storage_uri at a Redis server. The default memory storage resets when the app restarts, and each worker process keeps its own count.
  • Handle a reverse proxy. If the app runs behind a proxy such as Nginx, wrap it in Werkzeug's ProxyFix. Otherwise, every visitor shares the proxy's IP address and its login limit.
  • Require longer passwords. The app's minimum is 8 characters to keep testing quick. NIST SP 800-63B asks for at least 15 when a password is the only login factor, plus a check against common passwords.
  • Shorten the session lifetime. Logging out deletes the cookie from that browser, but a copy stolen earlier keeps working for up to 31 days by default. Storing sessions on the server instead ends every copy at logout.

If you keep sessions in the cookie, you can still expire unused ones sooner. Set session.permanent = True at login and lower PERMANENT_SESSION_LIFETIME.

13. How the App Stops Cross-Site Request Forgery (CSRF)

In a CSRF attack, another website hides a form that submits to your app. It hopes the visitor's browser will send their session cookie along with it.

Four pieces work together to stop this:

  • CSRFProtect. CSRFProtect(app) in main.py rejects every POST request that doesn't include a valid token.
  • The hidden field. Each form includes the token in a hidden csrf_token field.
  • The cookie setting. SameSite=Lax tells the browser not to send the session cookie with form posts from other websites.
  • The form_error() function. When a form post arrives from another website, form_error() answers with a redirect instead of a page, so the reply can't replace the visitor's session cookie.

Flask-WTF normally expires tokens after an hour. Setting WTF_CSRF_TIME_LIMIT to None keeps them valid for the whole session, so a page left open still works. The OWASP CSRF Cheat Sheet allows this.

The redirect in form_error() matters because csrf_token() saves its token in the session. If the app showed the form to a request without the session cookie, it would start a new session. That new cookie would replace the user's real one and log them out.

14. Common Problems and How to Fix Them

Here are fixes for the most common errors. Most headings quote the exact message you'll see.

Activate.ps1 cannot be loaded because running scripts is disabled on this system

PowerShell blocks activation scripts by default. Run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser once, or use Command Prompt instead.

ModuleNotFoundError: No module named 'mysql'

The virtual environment isn't active in this terminal. Activate it again, since every new terminal starts without it. The same fix works for "No module named 'dotenv'" and for a flask command that isn't found.

If the virtual environment is already active, run the pip install command from section 1.2 again.

ModuleNotFoundError: No module named 'flask_mysqldb'

Your code comes from an older version of this tutorial, which used Flask-MySQLdb. First, copy db_config, get_db(), close_db() and the load_dotenv() call from this tutorial, along with their imports, and create the .env file from section 4.

Then replace each mysql.connection.cursor(MySQLdb.cursors.DictCursor) with get_db().cursor(prepared=True, dictionary=True).

Finally, remove MySQL(app), its imports and every mysql.connection.commit() call. The %s placeholders can stay as they are.

Error: Could not import 'main'.

You ran the flask command outside the pythonlogin folder. Move into that folder, activate the virtual environment and try again.

Set SECRET_KEY in the .env file to a long random string first!

The app didn't find a real secret key. Make sure the file is called .env, not .env.txt, and that it sits next to main.py. Then replace the placeholder with a random string of at least 32 characters.

2003 (HY000): Can't connect to MySQL server on 'localhost:3306'

MySQL isn't running, or it uses a different port. Start the MySQL service in the Services app on Windows, in System Settings on macOS, or with sudo systemctl start mysql on Linux. On Fedora and Red Hat, the service is called mysqld.

If your MySQL server uses a different port, set DB_PORT in .env to that port.

1045 (28000): Access denied for user 'root'@'localhost'

MySQL rejected the username or password in .env. If the message ends with "using password: NO", DB_PASSWORD in .env is empty. Fix DB_USER and DB_PASSWORD in .env, or create a separate MySQL user with the lines for error 1698.

1698 (28000): Access denied for user 'root'@'localhost'

On Ubuntu and Debian, MySQL's root user can only log in through sudo, so create a separate user for the app. Run sudo mysql, then enter these two lines, replacing choose-a-strong-password with a password of your own:

SQL
CREATE USER 'pythonlogin'@'localhost' IDENTIFIED BY 'choose-a-strong-password';
GRANT ALL PRIVILEGES ON pythonlogin.* TO 'pythonlogin'@'localhost';

Then set DB_USER to pythonlogin and DB_PASSWORD to that password in .env. Run schema.sql in MySQL Workbench through a new connection with pythonlogin as the username, or with sudo mysql -e "source schema.sql" from the pythonlogin folder.

1049 (42000): Unknown database 'pythonlogin'

The database doesn't exist yet. Run schema.sql as shown in section 3, or fix DB_NAME in .env.

jinja2.exceptions.TemplateNotFound: register.html

Flask looks for templates in a folder called templates, next to main.py. Check the folder's name, and make sure the file isn't saved as register.html.txt.

BuildError: Could not build url for endpoint 'home'

A link or redirect points to a route that doesn't exist yet. While you're building the app, this is expected until you add that route. The home route comes in section 8, profile in section 9 and logout in section 10.

If you've added every route, check that the name in url_for() matches the route's function name.

The form has expired! Please try again.

The form's CSRF token is missing or belongs to an older session, which can happen after you log out in another tab. Reload the page and try again. Every POST form you create needs the hidden csrf_token field from index.html.

Port 5000 is in use by another program.

Another program is already using port 5000. On macOS, it's often AirPlay Receiver. On Windows, the error reads "An attempt was made to access a socket in a way forbidden by its access permissions."

Start the app on another port with flask --app main run --debug --port 5001, and open http://localhost:5001 instead.

Accounts from an older version of this tutorial can't log in

Older versions of this tutorial stored SHA-1 or plain-text passwords, which check_password_hash() can't verify. On a test database, the simplest fix is to drop the old accounts table and run schema.sql again.

With real users, back up the table first. Then merge any duplicate usernames and emails, and add the registered column and the unique keys from schema.sql.

Old passwords don't have the scrypt: prefix that new hashes start with. When one of those users logs in, check their password the way the old code did, using the old secret key for SHA-1.

If it matches, save a new hash with generate_password_hash(password, method=PASSWORD_HASH_METHOD).

15. Frequently Asked Questions

Should you use Flask-Login for a Flask login system?

You don't need Flask-Login for a simple login system like this one. Flask's session and a login_required decorator cover it, while Flask-Login adds extras such as current_user and remember-me cookies for bigger apps.

How do you add a remember-me checkbox to a Flask login form?

Set session.permanent to True when the user ticks the remember-me box. The login then survives closing the browser and lasts for PERMANENT_SESSION_LIFETIME, which is 31 days by default.

Can you use SQLite instead of MySQL with Flask?

Yes, but the database code and schema.sql both change. Use Python's built-in sqlite3 module with question-mark placeholders, and note that SQLite returns dates as text, so the profile template needs to format them differently.

Conclusion

Your Flask app now registers users, checks passwords against scrypt hashes and keeps the home and profile pages private. The same routes, templates and sessions are the building blocks of larger Flask apps too.

Here are some ideas to build next, from quickest to hardest:

  • Last login. Add a last_login column and update it each time someone logs in.
  • Change password. Add a form that checks the current password with check_password_hash() before saving a new hash.
  • Return to the requested page. After login, send users back to the page they were trying to open. Pass it in a next parameter, and only allow paths on your own site.

Need email activation, password resets, two-factor authentication or an admin panel? The Advanced Package adds all of them.

Prefer another language? Follow our PHP secure login system and registration system tutorials, or our Node.js and Express login tutorial.

This tutorial was rewritten in 2026, so older comments may refer to the previous version, which used Flask-MySQLdb. If you get stuck, post the full error message in the comments.

You have the complete tutorial code above, free to use. If you want the production-ready build of this project, or everything on the site at once, here is how they compare.

Advanced Secure Login & Registration System

The production build of this one project

$20 one payment
View the package

Instant download after payment

  • Passkeys (fingerprint, face or screen lock)
  • Two-factor authentication with recovery codes
  • Google & GitHub login
  • Admin panel with roles, invitations, CSV import & an audit log
  • Email activation & password reset
  • Argon2id password hashing & account lockouts
  • Logged-in devices & new login alerts
  • Just this project, not the other 15
  • Ads stay on the page
Show all 19 features
  • CSRF protection & rate limits
  • Cloudflare Turnstile & reCAPTCHA v3
  • Editable email templates
  • Data export & account deletion
  • Responsive design & dark mode
  • Docker setup & deployment guide
  • Over 250 automated tests
  • Upgrade command for version 1 databases
  • Clean, commented source code
  • Free updates & support (bugs and minor issues)
  • User guide
  • Extra: tutorial source code
Paid once, yours to keep. Handled by PayPal or Stripe.
Best value

CodeShack Pro

Every package and premium tool, for less than one costs

$8 /month
Go Pro

Billed yearly at $96. Save $48 against monthly.

  • All 16 packages, worth $360 bought one at a time
  • Every premium tool on the site
  • No ads on any page of the site
  • Members-only tools and articles
  • Everything we add while you are a member
  • Cancel from your account in one click
Card handled by Stripe. Cancel any time, keep what you downloaded.
What you just built stays freeThe complete source from this tutorial, free for personal and commercial use, with no attribution required. Download the ZIP