Build a Mock Bank Website with Flask

Build a full-stack banking app with login, transfers, and a database.

Introduction

30 Second Summary

Every time you log into your bank, check your balance, or send money to a friend, there's a web application handling all of that behind the scenes. Ever wondered how those systems actually work?

In this project, you will build PyBank, a mock banking website using Python and Flask. You'll create user registration, login authentication, account dashboards, and a money transfer system from scratch.

What You'll Build

You'll open PyBank in your browser, register an account, log in to a styled dashboard showing your balance, and send money to other users while watching both accounts update in real time.

By the end of this project, you'll have:

  • Register a bank account and see your hashed password safely stored in a real SQLite database, never in plain text.
  • Log in once and stay authenticated across pages using Flask sessions, with a protected dashboard displaying your live balance and recent transaction history.
  • Send fake money to other users and watch both account balances update instantly, with error messages catching insufficient funds and invalid recipients.
  • Secret Mission: Add a dedicated transaction history page with amount filtering and pagination.

Are there any prerequisites?

No prior web development experience is needed. This project walks you through every step from creating your first Python file to running a complete web application in your browser.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Project

To build PyBank, you need a clean workspace where your banking app's code and packages live isolated from the rest of your system.

In this step, you'll create a project folder, set up a Python virtual environment, install Flask, and verify everything works by running a minimal app in your browser.

In this step, get ready to:
  • Create a project folder with a Python virtual environment.
  • Install Flask and run a minimal app in the browser.
Create your project folder and virtual environment

A virtual environment is a self-contained Python installation inside your project folder. It keeps your project's packages separate from your system Python so nothing conflicts.

  • In VS Code, press Ctrl+` to open the integrated terminal.
  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir pybank
cd pybank
  • Create a virtual environment and activate it by running:
python3 -m venv .venv
source .venv/bin/activate

You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

What is a virtual environment?

Think of it like a sealed box for your project. Any packages you install while the virtual environment is active only exist inside this box. Your system Python stays untouched, and other projects on your machine stay unaffected.

Don't see (.venv) in your prompt?

Make sure you ran the source command exactly as shown. If you closed the terminal, re-open it and run source .venv/bin/activate again from inside the pybank folder.

Still stuck? Why isn't my virtual environment activating?

  • Install Flask inside your virtual environment by running:
pip install Flask==3.1.3

You should see output ending with Successfully installed Flask-3.1.3 and a list of its dependencies.

Seeing a permission error or 'pip not found'?

Make sure (.venv) is visible in your prompt before running pip install. If it is missing, run source .venv/bin/activate first. The virtual environment must be active so pip installs into the project, not your system.

Need more help? My pip install is failing inside my virtual environment.

Create a minimal Flask app to verify the install

Flask needs at least one route to serve a page. You'll create the simplest possible app to confirm everything is wired up correctly.

  • In VS Code's Explorer sidebar (left panel), click the New File icon at the top of the file tree.
  • Name the file app.py and press Enter.
  • Paste the following code into app.py:
from flask import Flask

# Create the Flask application
app = Flask(__name__)

# Define the homepage route
@app.route('/')
def index():
    return 'Hello, World!'

What does this code do?

  • from flask import Flask brings in the Flask class from the package you just installed.
  • app = Flask(__name__) creates your web application. Flask uses __name__ to know where to find templates and static files later.
  • @app.route('/') tells Flask that when someone visits the homepage URL, run the function below it.
  • The index() function returns the text that appears in the browser.
  • Save app.py by pressing Ctrl+S.

Before you run the server, what do you think will happen when you visit the homepage URL? What text will appear in the browser?

  • Start the Flask development server by running this command in your terminal:
flask --app app run --debug

You should see output that includes Running on http://127.0.0.1:5000 in the terminal. The server is now running.

  • Open your browser and visit http://127.0.0.1:5000.

You should see Hello, World! displayed on the page. Your Flask app is live.

What does --debug do?

The --debug flag enables two features. First, it auto-reloads the server whenever you save a file change. Second, it shows detailed error messages in the browser if something breaks.

Not seeing Hello, World! in the browser?

  • Check that the terminal still shows the server running. If you see an error instead, read the message carefully for a filename or line number.
  • Make sure the URL is exactly http://127.0.0.1:5000 with no trailing path.
  • Confirm your file is named app.py (not App.py or flask.py). The name matters because flask --app app looks for a file called app.py.

Still stuck? My Flask server won't start or my browser shows an error.

✔️ Awesome, I've got everything!

Great. Double check you've saved app.py and that the server is still running in your terminal.

ⓧ I'd like to double check the full code

Here is the complete app.py you should have at this point:

from flask import Flask

# Create the Flask application
app = Flask(__name__)

# Define the homepage route
@app.route('/')
def index():
    return 'Hello, World!'

Your environment is ready and Flask is serving pages. Next up, you'll build the actual bank pages with HTML templates and CSS styling.

Build Your Bank's Pages

In Step 1 you proved Flask is working by showing "Hello, World!" in the browser. That plain text is a start, but a real bank needs styled pages with navigation, buttons, and a dashboard.

Before adding any logic like user accounts or transfers, you need something visible. A styled homepage and dashboard page prove your templates and CSS are working. They give you a tangible bank to build on in the next steps.

In this step, get ready to:
  • Create a base template with navigation and a shared page skeleton.
  • Build a styled homepage and a hardcoded dashboard page.
  • Connect Flask routes to your new templates so the browser renders real HTML.
Set up the template structure

Flask uses a templates/ folder for HTML files and a static/ folder for CSS, images, and JavaScript. You need to create both inside your project folder.

  • Create the template and static folders by running these commands in your terminal:
mkdir templates static
  • Confirm both folders exist by running:
ls

You should see templates and static listed alongside your existing app.py and .venv folder.

Every page in PyBank will share the same navigation bar and page structure. Instead of repeating that HTML in every file, Flask's Jinja2 templating engine lets you write a base template that child pages extend.

  • Create the file templates/base.html and paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}PyBank{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
    <nav>
        <a href="{{ url_for('index') }}" class="logo">PyBank</a>
        <div class="nav-links">
            {% if session.get('user_id') %}
                <a href="{{ url_for('dashboard') }}">Dashboard</a>
                <a href="{{ url_for('transfer') }}">Transfer</a>
                <a href="{{ url_for('logout') }}">Log Out</a>
            {% else %}
                <a href="{{ url_for('login') }}">Log In</a>
                <a href="{{ url_for('register') }}">Register</a>
            {% endif %}
        </div>
    </nav>
    <main>
        {% for message in get_flashed_messages() %}
            <div class="flash">{{ message }}</div>
        {% endfor %}
        {% block content %}{% endblock %}
    </main>
</body>
</html>

What does this template do?

This is the skeleton every page inherits. The {% block title %} and {% block content %} tags are placeholders that child templates fill in with their own content.

The {% if session.get('user_id') %} block switches the nav links based on whether someone is logged in. Right now nobody can log in, so you will always see the Log In and Register links.

Next, create the homepage template that extends this base.

  • Create the file templates/index.html and paste the following code:
{% extends 'base.html' %}

{% block title %}PyBank - Home{% endblock %}

{% block content %}
<div class="hero">
    <h1>Welcome to PyBank</h1>
    <p>Your trusted fake bank for learning web development.</p>
    <div class="hero-buttons">
        <a href="{{ url_for('register') }}" class="btn">Open an Account</a>
        <a href="{{ url_for('login') }}" class="btn btn-outline">Log In</a>
    </div>
</div>
{% endblock %}

What does extends do?

The {% extends 'base.html' %} line tells Jinja2 to use the base template as the page skeleton. The {% block content %} section then fills in the main area with the homepage hero.

Now you need to tell Flask to render this template instead of returning plain text.

  • Open app.py and replace all the existing code with this:
from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/dashboard')
def dashboard():
    return render_template('dashboard.html')

What changed?

You imported render_template from Flask. Now each route returns render_template('filename.html') instead of a raw string. Flask looks for these files inside the templates/ folder automatically.

The /dashboard route points to a template you have not created yet. You will build that shortly.

  • Save app.py.
  • Switch to your browser and refresh http://127.0.0.1:5000.

You should see the PyBank heading and the "Open an Account" and "Log In" buttons. The page has no colour or layout yet because the stylesheet is empty.

Seeing a TemplateNotFound error?

Make sure your templates/ folder is in the same directory as app.py (inside pybank/). Also confirm the filename is exactly index.html with no typos.

help me fix a TemplateNotFound error in Flask

Time to add the CSS that makes PyBank look like a real bank. You will build the stylesheet in sections, refreshing between each to see the effect.

  • Create the file static/style.css and paste the following foundation styles:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background-color: #f0f2f5;
    color: #1a1a2e;
    line-height: 1.6;
}

nav {
    background-color: #1a1a2e;
    padding: 1rem 2rem;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

nav .logo {
    color: #4ecca3;
    font-size: 1.5rem;
    font-weight: bold;
    text-decoration: none;
}
  • Save style.css and refresh your browser.

You should see a dark navy navigation bar at the top with the green PyBank logo. The page background is now light grey instead of white.

  • Add the following styles below the existing code in style.css:
nav .nav-links a {
    color: #e0e0e0;
    text-decoration: none;
    margin-left: 1.5rem;
}

nav .nav-links a:hover {
    color: #4ecca3;
}

main {
    max-width: 800px;
    margin: 2rem auto;
    padding: 0 1rem;
}

.flash {
    background-color: #fff3cd;
    border: 1px solid #ffc107;
    color: #856404;
    padding: 0.75rem 1rem;
    border-radius: 4px;
    margin-bottom: 1rem;
}

.hero {
    text-align: center;
    padding: 4rem 1rem;
}
  • Save and refresh.

The nav links now appear in light grey text. The hero section is centred on the page with generous padding.

  • Continue adding these styles below the previous code in style.css:
.hero h1 {
    font-size: 2.5rem;
    margin-bottom: 0.5rem;
    color: #1a1a2e;
}

.hero p {
    font-size: 1.2rem;
    color: #555;
    margin-bottom: 2rem;
}

.hero-buttons {
    display: flex;
    gap: 1rem;
    justify-content: center;
}

.btn {
    display: inline-block;
    background-color: #4ecca3;
    color: #1a1a2e;
    padding: 0.75rem 1.5rem;
    border: none;
    border-radius: 4px;
    font-size: 1rem;
    font-weight: bold;
    text-decoration: none;
    cursor: pointer;
}
  • Add the button variants and form styles below in style.css:
.btn:hover {
    background-color: #3dbb91;
}

.btn-outline {
    background-color: transparent;
    border: 2px solid #4ecca3;
    color: #4ecca3;
}

.btn-outline:hover {
    background-color: #4ecca3;
    color: #1a1a2e;
}

.form-container {
    background: white;
    padding: 2rem;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    max-width: 400px;
    margin: 2rem auto;
}

.form-container h1 {
    margin-bottom: 1.5rem;
    text-align: center;
}
  • Save style.css and refresh your browser.

The homepage is now fully styled. You should see two buttons side by side: a solid green "Open an Account" button and a transparent outlined "Log In" button.

Buttons not showing any colour?

Check that style.css is inside the static/ folder, not templates/. Flask only serves static files from the static/ directory.

help me debug why my CSS is not loading in Flask

Create the dashboard page

The homepage looks great. Now you need a dashboard that shows a user's account balance. For now you will hardcode the balance at $1,000.00 and connect it to real data in a later step.

  • Create the file templates/dashboard.html and paste the following code:
{% extends 'base.html' %}

{% block title %}Dashboard - PyBank{% endblock %}

{% block content %}
<div class="dashboard">
    <h1>Welcome</h1>
    <div class="balance-card">
        <p class="balance-label">Current Balance</p>
        <p class="balance-amount">$1,000.00</p>
    </div>
</div>
{% endblock %}

Why hardcode the balance?

This template will eventually pull real data from a database. For now, a hardcoded value lets you confirm the page structure and CSS are correct before adding complexity.

  • Save dashboard.html and visit http://127.0.0.1:5000/dashboard in your browser.

You should see the navigation bar and a "Welcome" heading. The balance card text is there but looks unstyled because the dashboard CSS classes are not in your stylesheet yet.

  • Add the remaining form input styles below the existing code in style.css:
.form-container form {
    display: flex;
    flex-direction: column;
}

.form-container label {
    margin-bottom: 0.25rem;
    font-weight: bold;
    color: #333;
}

.form-container input {
    padding: 0.75rem;
    margin-bottom: 1rem;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 1rem;
}

.form-container input:focus {
    outline: none;
    border-color: #4ecca3;
}

.form-container .btn {
    margin-top: 0.5rem;
}
  • Add the form link styles and dashboard card styles below in style.css:
.form-container p {
    text-align: center;
    margin-top: 1rem;
    color: #666;
}

.form-container p a {
    color: #4ecca3;
}

.dashboard {
    padding: 1rem 0;
}

.dashboard h1 {
    margin-bottom: 1.5rem;
}

.balance-card {
    background: white;
    padding: 2rem;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    text-align: center;
    margin-bottom: 1.5rem;
}
  • Save style.css and refresh the dashboard page.

You should see a white card centred on the page. The balance text is inside it but still in default font size and colour.

  • Add the balance display and transaction table styles below in style.css:
.balance-label {
    color: #666;
    font-size: 0.9rem;
    text-transform: uppercase;
}

.balance-amount {
    font-size: 2.5rem;
    font-weight: bold;
    color: #4ecca3;
}

.actions {
    margin-bottom: 1.5rem;
}

.transactions {
    background: white;
    padding: 1.5rem;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.transactions h2 {
    margin-bottom: 1rem;
}
  • Add the final table styles below in style.css:
.transactions table {
    width: 100%;
    border-collapse: collapse;
}

.transactions th,
.transactions td {
    padding: 0.75rem;
    text-align: left;
    border-bottom: 1px solid #eee;
}

.transactions th {
    color: #666;
    font-size: 0.85rem;
    text-transform: uppercase;
}
  • Save style.css and refresh the dashboard page.

The dashboard now shows "CURRENT BALANCE" as an uppercase grey label with a large green $1,000.00 below it. The transaction table styles are ready for when you add real data in a later step.

Why add styles you cannot see yet?

The .form-container and .transactions styles are used by pages you will build in upcoming steps (registration, login, and the full dashboard with transaction history). Adding them now means you never have to touch this CSS file again.

Verify both pages and notice the security gap

Before you run this, think about what should happen if someone who is not logged in visits the dashboard URL directly. Should they be allowed to see it?

  • Visit http://127.0.0.1:5000 in your browser.
  • Confirm you see the styled PyBank homepage with navigation links showing "Log In" and "Register".
  • Now visit http://127.0.0.1:5000/dashboard directly.

You see the dashboard page with a hardcoded balance of $1,000.00. Anyone who knows this URL can see it without any login. That is the security gap you will fix in Step 4 when you add authentication.

Why is this a problem?

In a real bank, only the account owner should see their balance. Right now, the /dashboard route has no protection at all. You will fix this by building a login system and a login_required decorator that blocks unauthenticated visitors.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all five files: app.py, templates/base.html, templates/index.html, templates/dashboard.html, and static/style.css.

ⓧ I'd like to double check the full code

Scroll up and confirm each code block is present in order. Your files should contain these sections:

  • app.py: imports render_template, has / and /dashboard routes.
  • templates/base.html: full HTML skeleton with nav, session check, flash messages, and content block.
  • templates/index.html: extends base.html with a hero section and two buttons.
  • templates/dashboard.html: extends base.html with a balance card showing $1,000.00.
  • static/style.css: all styles from global reset through .transactions th in the order you added them.

Your bank has a face now. Two styled pages are live and the template system is ready for every page you will build next. In the next step, you will add a real database so users can register with hashed passwords.

Store Users in a Database

Your dashboard currently shows a hardcoded balance of $1,000.00. That number is just text in an HTML file. It does not come from anywhere real, and it shows the same thing to everyone.

To give each user their own account with their own balance, you need a database. In this step, you will create a SQLite database to store usernames and hashed passwords, then build a registration page so users can sign up.

In this step, get ready to:
  • Create a database schema with tables for users and transactions.
  • Add database helper functions and a CLI command to initialize the database.
  • Build a registration page that creates new users with hashed passwords.
Create the database schema

A database schema defines the structure of your tables. You need two tables: one for user accounts and one for recording money transfers.

  • Create a new file called schema.sql in the root of your pybank folder (the same directory as app.py) by running:
touch schema.sql
  • Open schema.sql in VS Code and paste the following:
DROP TABLE IF EXISTS transaction_log;
DROP TABLE IF EXISTS user;

CREATE TABLE user (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT UNIQUE NOT NULL,
    password TEXT NOT NULL,
    balance REAL NOT NULL DEFAULT 1000.00
);

CREATE TABLE transaction_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    sender_id INTEGER NOT NULL,
    receiver_id INTEGER NOT NULL,
    amount REAL NOT NULL,
    timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (sender_id) REFERENCES user (id),
    FOREIGN KEY (receiver_id) REFERENCES user (id)
);

What does this schema do?

The user table stores each account with a unique username, a hashed password, and a starting balance of $1,000.00. The UNIQUE constraint on username prevents duplicate registrations.

The transaction_log table records every transfer between users. Each row tracks who sent money, who received it, how much, and when it happened.

  • Save schema.sql.
  • Confirm the file exists by running:
ls schema.sql

You should see schema.sql listed in the output.

Add database helpers to your app

Flask needs functions to connect to the database, close the connection when a request finishes, and a CLI command to run your schema. You will replace the top of app.py with expanded imports and add these helpers.

  • Open app.py and find these two lines at the very top:
from flask import Flask, render_template

app = Flask(__name__)
  • Replace those lines with the following imports and configuration:
import os
import sqlite3
from functools import wraps

from flask import (
    Flask, render_template, request, redirect,
    url_for, session, flash, g
)
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__)
app.secret_key = 'your-secret-key-here'
DATABASE = os.path.join(app.instance_path, 'pybank.db')

os.makedirs(app.instance_path, exist_ok=True)

What does this configuration do?

The secret_key lets Flask encrypt session cookies so users can stay logged in across pages. DATABASE points to a file inside Flask's instance/ folder, which is meant for data you do not commit to version control.

The os.makedirs line creates that instance/ folder automatically if it does not exist yet.

  • Now add the database helper functions directly below the os.makedirs line and above the @app.route('/') line:
def get_db():
    if 'db' not in g:
        g.db = sqlite3.connect(DATABASE)
        g.db.row_factory = sqlite3.Row
    return g.db


@app.teardown_appcontext
def close_db(exception):
    db = g.pop('db', None)
    if db is not None:
        db.close()


def init_db():
    db = get_db()
    with app.open_resource('schema.sql') as f:
        db.executescript(f.read().decode('utf-8'))


@app.cli.command('init-db')
def init_db_command():
    init_db()
    print('Database initialized.')

What do these functions do?

  • get_db() opens a connection to the SQLite database and stores it on Flask's g object so it is reused within the same request.
  • close_db() automatically closes the connection when the request ends.
  • init_db() reads schema.sql and runs it against the database to create your tables.
  • init_db_command() registers a custom Flask CLI command so you can type flask --app app init-db in the terminal.
  • Save app.py.

Now initialize the database. Your Flask dev server is using your current terminal, so you need a second one.

  • In VS Code, click the + button in the terminal panel to open a new terminal.
  • Activate your virtual environment and move into your project folder by running:
cd ~/Desktop/pybank
source .venv/bin/activate
  • Initialize the database by running:
flask --app app init-db

You should see Database initialized. printed in the terminal.

  • Confirm the database file was created by running:
ls instance/

You should see pybank.db in the output.

Seeing an error when running init-db?

  • Make sure your virtual environment is activated. You should see (.venv) at the start of your terminal prompt.
  • Check that schema.sql is in the same folder as app.py. Flask looks for it relative to the application root.
  • Confirm you saved app.py after adding the new code. Unsaved changes are invisible to the CLI.

Still stuck? Help me debug my flask --app app init-db command

Build the registration page

With the database ready, you can build a registration form that creates new user accounts. The route needs to handle both displaying the form (GET) and processing the submission (POST).

  • In app.py, find the @app.route('/') line. Add the following register route directly above it:
@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        db = get_db()
        error = None

        if not username:
            error = 'Username is required.'
        elif not password:
            error = 'Password is required.'

        if error is None:
            try:
                db.execute(
                    'INSERT INTO user (username, password) VALUES (?, ?)',
                    (username, generate_password_hash(password))
                )
                db.commit()
            except sqlite3.IntegrityError:
                error = f'User {username} is already registered.'
            else:
                flash('Registration successful! Please log in.')
                return redirect(url_for('login'))

        flash(error)

    return render_template('register.html')

What does this route do?

When someone visits /register with a GET request, Flask renders the registration form. When they submit the form (POST), the route validates the input, hashes the password using generate_password_hash(), and inserts the new user into the database.

The try/except block catches duplicate usernames because the user table has a UNIQUE constraint on the username column. If the username already exists, SQLite raises an IntegrityError instead of crashing.

What is password hashing?

Hashing transforms a password into a long, unreadable string that cannot be reversed. Even if someone accesses your database, they cannot read the original passwords. The generate_password_hash() function comes from werkzeug, which is already installed as a Flask dependency.

  • Save app.py.
  • Now create the registration template. Create a new file called templates/register.html and paste the following:
{% extends 'base.html' %}

{% block title %}Register - PyBank{% endblock %}

{% block content %}
<div class="form-container">
    <h1>Create Account</h1>
    <form method="POST">
        <label for="username">Username</label>
        <input type="text" name="username" id="username" required>
        <label for="password">Password</label>
        <input type="password" name="password" id="password" required>
        <button type="submit" class="btn">Register</button>
    </form>
    <p>Already have an account? <a href="{{ url_for('login') }}">Log in</a></p>
</div>
{% endblock %}

What does this template do?

This template extends base.html so it inherits the navigation bar and styling. The form uses method="POST" to send the username and password to the same /register route for processing. The required attribute adds browser-level validation before the form is even submitted.

  • Save templates/register.html.
  • Visit http://127.0.0.1:5000/register in your browser.

You should see a styled registration form with username and password fields inside a white card.

Page not loading or showing an error?

  • Check that your Flask dev server is still running in your first terminal. If it stopped, restart it with flask --app app run --debug.
  • Make sure register.html is inside the templates/ folder, not the project root.
  • Look for red error text in your terminal. A common cause is a typo in the import lines at the top of app.py.

Need more help? Help me debug why my /register page is not loading

Test the registration flow

The registration form is visible, but does it actually create a user in the database? Before you test, think about what you expect to happen when you submit valid credentials.

  • In the registration form, enter alice for the username.
  • Enter password123 for the password.
  • Click Register.

What do you expect to see after submitting? The route calls redirect(url_for('login')) on success, but you have not created a login route yet.

You will see an error page because the /login route does not exist yet. That is expected. Look at the flash message area at the top of the page before the redirect fails. The important thing is that the user was created in the database.

Why does this error happen?

The register route tries to redirect to url_for('login') after a successful registration. Since you have not built the login route yet, Flask cannot resolve that URL. You will fix this in the next step when you build the login system.

  • To confirm the user was actually created, verify it directly. In your second terminal, run:
python3 -c "import sqlite3; conn = sqlite3.connect('instance/pybank.db'); print(conn.execute('SELECT username, balance FROM user').fetchall()); conn.close()"

You should see [('alice', 1000.0)] printed in the terminal. Your user was created with the default balance of $1,000.00.

  • Now test the duplicate username check. Go back to http://127.0.0.1:5000/register and try registering with the username alice again.

You should see a yellow flash message saying the user is already registered. The form stays on the same page without crashing.

Not seeing the flash message?

  • Check that base.html contains the get_flashed_messages() loop. Flash messages only appear if the template renders them.
  • Make sure app.secret_key is set in app.py. Sessions and flash messages require a secret key to work.

Still stuck? Help me figure out why flash messages are not showing up

✔️ Awesome, I've got everything!

Great. Make sure you have saved both app.py and schema.sql.

ⓧ I'd like to double check the full code

Scroll up and confirm your app.py has all of these sections in order:

  • Imports (os, sqlite3, wraps, Flask imports, werkzeug imports).
  • App configuration (secret_key, DATABASE, os.makedirs).
  • Database helpers (get_db, close_db, init_db, init_db_command).
  • The /register route.
  • The / route (unchanged from before).
  • The /dashboard route (unchanged from before).

Your schema.sql should have the user and transaction_log tables with the columns shown in the schema chunk above.

Your templates/register.html should extend base.html and contain a form with username and password fields.

You now have a working database and user registration. The /login route does not exist yet, which is why the redirect fails after successful registration. Next up, you will build the login system and protect the dashboard so only authenticated users can see their accounts.

Add Login and Protect Your Pages

In the previous step, you noticed something important: anyone can visit /dashboard just by typing the URL. There is no check for who is visiting.

That is the core security flaw we fix now. You will build a login system that verifies credentials against the database, then write a decorator that locks pages behind authentication. Only logged-in users will be able to reach the dashboard.

In this step, get ready to:
  • Build login and logout routes that manage user sessions.
  • Create a reusable decorator that protects any route from anonymous visitors.
  • Verify that unauthenticated users get redirected to the login page.
Build the login and logout system

The login page needs a form just like registration. Behind the scenes, the route looks up the username in the database and compares the submitted password against the stored hash.

  • Create a new file called login.html inside your templates/ folder by pasting the following code:
{% extends 'base.html' %}

{% block title %}Log In - PyBank{% endblock %}

{% block content %}
<div class="form-container">
    <h1>Log In</h1>
    <form method="POST">
        <label for="username">Username</label>
        <input type="text" name="username" id="username" required>
        <label for="password">Password</label>
        <input type="password" name="password" id="password" required>
        <button type="submit" class="btn">Log In</button>
    </form>
    <p>Don't have an account? <a href="{{ url_for('register') }}">Register</a></p>
</div>
{% endblock %}

What does this template do?

This template extends base.html just like the register page. It has a POST form with username and password fields. The link at the bottom sends users to registration if they do not have an account yet.

  • Save templates/login.html.

Now you need the route in app.py that handles this form. When a user submits valid credentials, the route stores their user_id in the session so Flask remembers them across page loads.

  • In app.py, find the /dashboard route at the bottom of the file. Add the following /login route above it:
@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        db = get_db()
        error = None
        user = db.execute(
            'SELECT * FROM user WHERE username = ?', (username,)
        ).fetchone()

        if user is None:
            error = 'Incorrect username.'
        elif not check_password_hash(user['password'], password):
            error = 'Incorrect password.'

        if error is None:
            session.clear()
            session['user_id'] = user['id']
            return redirect(url_for('dashboard'))

        flash(error)

    return render_template('login.html')

What does this route do?

  • On GET requests, it simply renders the login form.
  • On POST, it looks up the username in the database with a parameterized SQL query (safe from injection).
  • check_password_hash() compares the submitted password against the stored hash without ever decrypting it.
  • session.clear() wipes any old session data, then session['user_id'] stores the logged-in user's ID. Flask encrypts this cookie using your secret_key.
  • Save app.py.
  • In your browser, visit http://127.0.0.1:5000/login. You should see the Log In form with username and password fields.

Don't see the login form?

  • Make sure both app.py and templates/login.html are saved. The debug server auto-reloads on save.
  • Check that login.html is inside the templates/ folder, not the project root.
  • help me debug my login page not loading

Now add the logout route. This clears the session and sends the user back to the homepage.

  • In app.py, add the following /logout route directly below the /login route you just added:
@app.route('/logout')
def logout():
    session.clear()
    flash('You have been logged out.')
    return redirect(url_for('index'))

What does this route do?

session.clear() removes all stored session data, including the user_id. After this, Flask treats the visitor as anonymous again. The flash message confirms the action.

  • Save app.py.
Create the login_required decorator and protect the dashboard

A decorator is a function that wraps another function to add behaviour. You will write one called login_required that checks whether a user is logged in before allowing access to a route.

  • In app.py, find the init_db_command() function. Add the following decorator function below it, above the @app.route('/') line:
def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'user_id' not in session:
            flash('Please log in to access this page.')
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function

What does this decorator do?

  • It takes any route function (f) and wraps it in a check.
  • If user_id is not in the session, the visitor gets redirected to the login page with a flash message.
  • @wraps(f) from functools preserves the original function's name so Flask's routing still works correctly.

Now apply this decorator to the /dashboard route so anonymous visitors can no longer reach it.

  • In app.py, scroll to the bottom and find the dashboard route:
@app.route('/dashboard')
def dashboard():
  • Add @login_required on a new line between @app.route('/dashboard') and def dashboard(): so it looks like this:
@app.route('/dashboard')
@login_required
def dashboard():
    return render_template('dashboard.html')
  • Save app.py.

Time to test the protection. Before you run this, what do you think will happen if you visit /dashboard without being logged in?

  • Open a new private/incognito browser window (this ensures no session cookie exists).
  • Visit http://127.0.0.1:5000/dashboard directly.

You should be redirected to the login page. A yellow flash message reading "Please log in to access this page." appears at the top. The decorator blocked the anonymous visit and sent you to log in.

Still seeing the dashboard without logging in?

  • Make sure you are using a private/incognito window. Your regular browser may have a session cookie from earlier testing.
  • Confirm that @login_required appears directly below @app.route('/dashboard') and above def dashboard():. The order matters.
  • help me figure out why my dashboard is not protected
Verify the navigation reflects login state

Your base.html template already contains conditional navigation that shows different links depending on whether session.get('user_id') is set. Now that your login and logout routes exist, this conditional is fully functional.

  • In your incognito window, look at the navigation bar on the login page.
  • You should see Log In and Register links. These are the links shown to anonymous visitors.

How does the conditional nav work?

In base.html, the {% if session.get('user_id') %} block checks whether a user is logged in. When true, it shows Dashboard, Transfer, and Log Out links. When false, it shows Log In and Register links.

This single conditional controls the entire navigation experience across every page in your app.

Now test the full login flow. What do you expect to happen when you submit valid credentials on the login page?

  • On the login page, enter the username and password you registered in the previous step.
  • Click Log In.

You should be redirected to the dashboard page showing the hardcoded balance of $1,000.00. The navigation bar now shows Dashboard, Transfer, and Log Out links.

Seeing an error about the 'transfer' endpoint?

If you see a BuildError mentioning the transfer endpoint, this is because the navigation references a Transfer route that you build in the next step. The login system itself is working correctly.

To confirm login works, visit http://127.0.0.1:5000/login and check that submitting valid credentials no longer shows the "Please log in" message. The Transfer route is added in Step 5 and resolves this error.

  • Now click Log Out in the navigation bar.
  • You should be redirected to the homepage with a flash message reading "You have been logged out."
  • The navigation bar should revert to showing Log In and Register links.

Logout not working?

  • Make sure the /logout route calls session.clear() before the redirect. Without this, the session cookie persists.
  • help me debug my logout route

✔️ Awesome, I've got everything!

Great. Make sure both app.py and templates/login.html are saved.

ⓧ I'd like to double check the full code

Your app.py should now contain these sections in order: imports, app setup, get_db/close_db/init_db/init_db_command, then the login_required decorator, then routes in this order: /, /register, /login, /logout, /dashboard (with @login_required). Scroll up and confirm each chunk is present in that order.

Here is the full templates/login.html for reference:

{% extends 'base.html' %}

{% block title %}Log In - PyBank{% endblock %}

{% block content %}
<div class="form-container">
    <h1>Log In</h1>
    <form method="POST">
        <label for="username">Username</label>
        <input type="text" name="username" id="username" required>
        <label for="password">Password</label>
        <input type="password" name="password" id="password" required>
        <button type="submit" class="btn">Log In</button>
    </form>
    <p>Don't have an account? <a href="{{ url_for('register') }}">Register</a></p>
</div>
{% endblock %}

Your bank now has a locked front door. Only users who prove their identity can reach the dashboard. Next up, you will connect real account balances from the database and build the transfer system that moves money between users.

Make Banking Work

Your bank has user accounts, login, and route protection. But the dashboard still shows a hardcoded $1,000.00 to everyone who logs in.

Now that users can authenticate, you can connect their real balance from the database and build a transfer system that moves money between accounts.

In this step, get ready to:
  • Connect the dashboard to real user data from the database.
  • Build a transfer system with full validation.
  • Test the complete flow by moving money between two users.
Connect real user data to the dashboard

The /dashboard route currently returns the template with no data. You need it to query the logged-in user's balance and their recent transactions from the database.

  • In app.py, find the existing dashboard() function (it currently has one line: return render_template('dashboard.html')).
  • Replace the entire route (from @app.route('/dashboard') through the end of the function) with this updated version:
@app.route('/dashboard')
@login_required
def dashboard():
    db = get_db()
    # Fetch the logged-in user's record
    user = db.execute(
        'SELECT * FROM user WHERE id = ?', (session['user_id'],)
    ).fetchone()
    # Fetch last 10 transactions involving this user
    transactions = db.execute(
        'SELECT t.amount, t.timestamp, '
        'sender.username AS sender_name, '
        'receiver.username AS receiver_name '
        'FROM transaction_log t '
        'JOIN user sender ON t.sender_id = sender.id '
        'JOIN user receiver ON t.receiver_id = receiver.id '
        'WHERE t.sender_id = ? OR t.receiver_id = ? '
        'ORDER BY t.timestamp DESC LIMIT 10',
        (session['user_id'], session['user_id'])
    ).fetchall()
    return render_template('dashboard.html', user=user, transactions=transactions)

What does this code do?

The first query fetches the logged-in user's row from the user table using the user_id stored in the session. This gives you their username and balance.

The second query JOINs the transaction_log table with the user table twice (once for sender, once for receiver) so each transaction shows usernames instead of raw IDs.

Now update the template to use this real data. In templates/dashboard.html, find these two lines:

    <h1>Welcome</h1>
  • Replace that line with:
    <h1>Welcome, {{ user['username'] }}</h1>
  • Next, find the hardcoded balance line:
        <p class="balance-amount">$1,000.00</p>
  • Replace it with this dynamic version:
        <p class="balance-amount">${{ '%.2f' % user['balance'] }}</p>
  • Save both app.py and templates/dashboard.html.
  • Refresh http://127.0.0.1:5000/dashboard in your browser (log in first if needed).

You should see "Welcome, [your username]" and a balance of $1,000.00 pulled from the database.

Seeing a TemplateSyntaxError?

Check that the {{ }} Jinja2 delimiters are present around user['username'] and '%.2f' % user['balance']. Missing a curly brace will cause a template error.

help me fix a TemplateSyntaxError in my dashboard.html

Now add the transaction history table and a transfer button. In templates/dashboard.html, find the closing </div> tag for the balance-card div. The next line is the closing </div> for .dashboard.

  • Add the following code between those two closing </div> tags:
    <div class="actions">
        <a href="{{ url_for('transfer') }}" class="btn">Make a Transfer</a>
    </div>
    <div class="transactions">
        <h2>Recent Transactions</h2>
        {% if transactions %}
            <table>
                <thead>
                    <tr>
                        <th>Date</th>
                        <th>From</th>
                        <th>To</th>
                        <th>Amount</th>
                    </tr>
                </thead>
                <tbody>
                    {% for t in transactions %}
                    <tr>
                        <td>{{ t['timestamp'] }}</td>
                        <td>{{ t['sender_name'] }}</td>
                        <td>{{ t['receiver_name'] }}</td>
                        <td>${{ '%.2f' % t['amount'] }}</td>
                    </tr>
                    {% endfor %}
                </tbody>
            </table>
        {% else %}
            <p>No transactions yet.</p>
        {% endif %}
    </div>

What does this code do?

The actions div adds a "Make a Transfer" button that links to the transfer page you will build next.

The transactions div uses a Jinja2 {% for %} loop to display each transaction in a table. If no transactions exist yet, it shows a friendly "No transactions yet." message.

  • Save templates/dashboard.html and refresh your browser.

You should see a green "Make a Transfer" button and the text "No transactions yet." below your balance card.

Don't see the transactions section?

Make sure you pasted the new code between the balance-card closing </div> and the .dashboard closing </div>. If you placed it outside the .dashboard div, the styling will not apply.

help me fix my dashboard.html layout

✔️ Awesome, I've got everything!

Great. Double check you have saved templates/dashboard.html.

ⓧ I'd like to double check the full code

Since the full file is longer than a quick glance, confirm you have these sections in order inside templates/dashboard.html:

  • The {% extends 'base.html' %} and title block at the top.
  • The <h1>Welcome, {{ user['username'] }}</h1> heading.
  • The balance-card div with the dynamic balance.
  • The actions div with the "Make a Transfer" button.
  • The transactions div with the table and the {% for t in transactions %} loop.
  • Closing </div> for .dashboard and {% endblock %} at the bottom.
Build the transfer system

The dashboard now shows real data, but there is no way to move money. You need a transfer page with a form and a route that validates the request before updating both account balances.

  • Create a new file at templates/transfer.html and paste the following:
{% extends 'base.html' %}

{% block title %}Transfer - PyBank{% endblock %}

{% block content %}
<div class="form-container">
    <h1>Transfer Funds</h1>
    <form method="POST">
        <label for="recipient">Recipient Username</label>
        <input type="text" name="recipient" id="recipient" required>
        <label for="amount">Amount ($)</label>
        <input type="number" name="amount" id="amount" step="0.01" min="0.01" required>
        <button type="submit" class="btn">Send Money</button>
    </form>
</div>
{% endblock %}

What does this template do?

The form collects a recipient username and a dollar amount. The step="0.01" attribute lets the browser accept cent values. The min="0.01" attribute prevents zero or negative amounts at the browser level (you will also validate server-side).

Now add the route that handles the form. The transfer logic needs to validate several things: the amount is positive, the sender has enough funds, the recipient exists, and the sender is not sending to themselves.

  • In app.py, add the first part of the transfer route at the bottom of the file. This handles input validation:
@app.route('/transfer', methods=['GET', 'POST'])
@login_required
def transfer():
    if request.method == 'POST':
        recipient = request.form['recipient']
        amount = request.form['amount']
        db = get_db()
        error = None

        # Convert amount to float, catch invalid input
        try:
            amount = float(amount)
        except ValueError:
            error = 'Invalid amount.'

        if error is None and amount <= 0:
            error = 'Amount must be positive.'

        # Check the sender has enough funds
        sender = db.execute(
            'SELECT * FROM user WHERE id = ?', (session['user_id'],)
        ).fetchone()

        if error is None and amount > sender['balance']:
            error = 'Insufficient funds.'
  • Directly below that code (still inside the if request.method == 'POST': block), add the rest of the transfer function:
        # Check the recipient exists and isn't the sender
        receiver = db.execute(
            'SELECT * FROM user WHERE username = ?', (recipient,)
        ).fetchone()

        if error is None and receiver is None:
            error = f'User {recipient} not found.'

        if error is None and receiver['id'] == session['user_id']:
            error = 'Cannot transfer to yourself.'

        # All checks passed - execute the transfer
        if error is None:
            db.execute(
                'UPDATE user SET balance = balance - ? WHERE id = ?',
                (amount, session['user_id'])
            )
            db.execute(
                'UPDATE user SET balance = balance + ? WHERE id = ?',
                (amount, receiver['id'])
            )
            db.execute(
                'INSERT INTO transaction_log (sender_id, receiver_id, amount) '
                'VALUES (?, ?, ?)',
                (session['user_id'], receiver['id'], amount)
            )
            db.commit()
            flash(f'Successfully transferred ${amount:.2f} to {recipient}.')
            return redirect(url_for('dashboard'))

        flash(error)

    return render_template('transfer.html')

What does the transfer route do?

On a GET request, it simply renders the transfer form. On a POST request, it walks through a chain of validation checks. Each check only runs if no previous error was found (if error is None and ...).

If all checks pass, three SQL statements run: subtract from the sender, add to the receiver, and log the transaction. Then db.commit() saves all three changes at once.

  • Save app.py.
  • In your browser, click the **Transfer** link in the navigation bar (or visit http://127.0.0.1:5000/transfer).

You should see the "Transfer Funds" form with fields for recipient username and amount.

Seeing a BuildError or 404?

A BuildError means Flask cannot find the transfer endpoint. Make sure you saved app.py after adding the route. Check that the function is named transfer() and is decorated with @app.route('/transfer', methods=['GET', 'POST']).

help me fix a BuildError for the transfer route

✔️ Awesome, I've got everything!

Great. Make sure both app.py and templates/transfer.html are saved.

ⓧ I'd like to double check the full code

Here is the complete templates/transfer.html:

{% extends 'base.html' %}

{% block title %}Transfer - PyBank{% endblock %}

{% block content %}
<div class="form-container">
    <h1>Transfer Funds</h1>
    <form method="POST">
        <label for="recipient">Recipient Username</label>
        <input type="text" name="recipient" id="recipient" required>
        <label for="amount">Amount ($)</label>
        <input type="number" name="amount" id="amount" step="0.01" min="0.01" required>
        <button type="submit" class="btn">Send Money</button>
    </form>
</div>
{% endblock %}

For app.py, confirm the transfer route is at the bottom and has these sections in order:

  • @app.route('/transfer', methods=['GET', 'POST']) decorator and @login_required.
  • Form data extraction (recipient and amount) and float conversion with try/except.
  • Validation checks for positive amount, sufficient funds, recipient exists, and no self-transfer.
  • Three SQL statements inside if error is None: (subtract, add, log) followed by db.commit().
  • flash(error) and return render_template('transfer.html') at the end.
Test the full flow

You have a dashboard pulling real data and a transfer route with validation. Time to prove the whole system works end to end.

  • If you only have one registered user, open a private/incognito browser window and visit http://127.0.0.1:5000/register.
  • Register a second user (for example, username alice with any password).
  • In your original browser window, log in as your first user.
  • Click Transfer in the navigation bar.

Before you submit the transfer, what do you think will happen to both users' balances if you send $250?

  • Enter the second user's username in the Recipient Username field.
  • Enter 250 in the Amount ($) field.
  • Click Send Money.

You should be redirected to your dashboard. Your balance now shows $750.00, and the transaction appears in the "Recent Transactions" table with the amount, sender, and receiver.

  • Switch to the private/incognito window and log in as the second user.
  • Visit the dashboard and confirm their balance shows $1,250.00 with the same transaction in their history.

Why do both users see the same transaction?

The dashboard query uses WHERE t.sender_id = ? OR t.receiver_id = ?. This means each user sees every transaction they were involved in, whether they sent or received the money.

Transfer not working?

If you see "User [name] not found", double check you typed the recipient's username exactly as they registered it (case-sensitive).

If the balance did not change, check that your transfer route includes db.commit() after the three SQL statements. Without it, the changes are never written to the database.

help me debug my transfer route

Your bank is fully functional. Users can register, log in, see their real balances, and transfer money with proper safety checks. Next up, you will push this further with a dedicated transaction history page.

Secret mission

Transaction History with Filtering

Your dashboard only shows the 10 most recent transactions. Real banks let you search, filter by amount, and page through your entire history. Can you build a dedicated history page with amount filtering and pagination?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The Flask dev server running in your terminal.
  • The pybank project folder on your Desktop (contains all code, templates, static files, the virtual environment, and the SQLite database).

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with PyBank.

  • Your Flask dev server continues running in the terminal. You can access PyBank at http://127.0.0.1:5000 any time.
  • Your pybank folder stays on your Desktop with all files intact.
  • The SQLite database retains all registered users and transaction records.

Pause - I'll come back to this later

Shut down the running server to free up your terminal. Your project files and database stay on disk so you can pick up where you left off.

  • In the terminal where Flask is running, press Ctrl+C to stop the dev server.
  • To resume later, open a terminal, navigate to your pybank folder, activate the virtual environment with source .venv/bin/activate, and run flask --app app run --debug.

Delete - I don't want to use this again

Remove all project files and start fresh. This permanently deletes your code, database, and virtual environment.

  • In the terminal where Flask is running, press Ctrl+C to stop the dev server.
  • Deactivate the virtual environment by running:
deactivate
  • Navigate to your Desktop and delete the entire project folder by running:
cd ~/Desktop
rm -rf pybank
  • Confirm the folder is gone by running ls and checking that pybank no longer appears.

Nice Work!

Nice Work!

You just built a fully functional mock banking website from scratch using Python and Flask. You can register users, log in, check balances, and transfer money between accounts.

You've learned how to:

  • Build a complete Flask web application with HTTP routing, HTML templates, and a SQLite database storing users and transactions.
  • Implement a session-based authentication system with secure password hashing, a reusable login_required decorator, and route protection that redirects unauthenticated visitors.
  • Design a transfer system with real validation logic that prevents insufficient funds, invalid recipients, and self-transfers.
  • Secret Mission: Added a dedicated transaction history page with amount filtering and pagination using SQL LIMIT, OFFSET, and query parameters.

Ready to quiz yourself?