Build a Secure Case Management System
Build a Flask web app with authentication, RBAC, and CSRF protection.
Introduction
30 Second Summary
Every app you use online protects your data behind login screens and permission checks. Banking portals, support ticket systems, and healthcare dashboards all rely on the same security patterns to keep the wrong people out and the right people in.
In this project, you will build a secure case management system using Python and Flask. You'll implement user authentication, role-based access control, and CSRF protection from scratch.
What You'll Build
A locally-running Flask web application where users can register, log in, and manage support cases with role-based permissions.
By the end of this project, you'll have:
- A Flask application with modular Blueprints separating authentication from case management logic.
- User authentication with secure password hashing using scrypt, protecting user credentials from exposure.
- Role-based access control where regular users manage their own cases and admins can view and manage all cases.
- Secret Mission: Add case search and filtering by status so admins can quickly find open cases.
Are there any prerequisites?
You need basic Python knowledge (variables, functions, and classes). No Flask experience is required. This project teaches you Flask from the ground up.
Set Up Your Python Environment
Before you can build a secure web application, you need a clean, isolated environment where your project dependencies live separately from your system Python.
In this step, you will verify your Python version, create a virtual environment, install Flask and its security extensions, and set up the folder structure for the project.
In this step, get ready to:
- Verify Python 3.10+ and create a virtual environment.
- Install Flask and its security extensions.
- Create the project folder structure for Blueprints and templates.
Verify Python and create your project
Flask-WTF 1.3.0 (the library that handles CSRF protection) requires Python 3.10 or higher. Let's confirm you have the right version installed.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Python version by running this command:
macOS
python3 --version
Windows
python --version
✔️ I see version 3.10 or higher
You are good to go. Your output looks something like Python 3.10.x or Python 3.11.x or higher.
ⓧ I see an older version
Your system has an older Python that does not meet the 3.10+ requirement. macOS often ships with an older default Python.
- Visit the official Python downloads page at python.org/downloads and download Python 3.10 or later.
- Run the installer and follow the prompts.
- After installation, close and reopen your terminal, then run the version check again.
macOS users with multiple versions
If python3 --version still shows the old version after installing, try python3.11 --version (or whichever version you installed) to confirm the new install works. Use that full binary name for the rest of this project.
ⓧ Command not found
Python is not installed on your system yet.
- Visit the official Python downloads page at python.org/downloads and download Python 3.10 or later.
- Run the installer. On Windows, make sure to check the box that says Add Python to PATH.
- After installation, close and reopen your terminal, then run the version check again.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir case-manager
cd case-manager
- Create a virtual environment by running this command:
macOS
python3 -m venv venv
Windows
python -m venv venv
What is a virtual environment?
A virtual environment is an isolated copy of Python for your project. Packages you install inside it stay separate from your system Python. This prevents version conflicts between different projects on your machine.
- Activate the virtual environment by running this command:
macOS
source venv/bin/activate
Windows
.\venv\Scripts\Activate.ps1
Seeing a script execution error?
If PowerShell blocks the activation script, run this command first to allow local scripts, then try the activation again:
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
Make sure you ran the activation command (not the creation command) and that it completed without errors. Try closing your terminal and reopening it in the case-manager folder, then run the activation command again.
help me activate my Python virtual environment
Install Flask and its extensions
With your virtual environment active, you can now install the four packages that power this project's web server, database, authentication, and form security.
- Install all project dependencies by running this command:
pip install Flask==3.1.3 Flask-SQLAlchemy==3.1.1 Flask-Login==0.6.3 Flask-WTF==1.3.0
What are these packages?
- Flask is the web framework that handles HTTP requests and responses.
- Flask-SQLAlchemy connects Flask to a SQLite database using an ORM (Object Relational Mapper).
- Flask-Login manages user sessions so your app remembers who is logged in.
- Flask-WTF adds CSRF protection and form validation to keep your app secure.
The install takes about 10-20 seconds. You should see a success message ending with Successfully installed followed by a list of packages.
Seeing installation errors?
Make sure (venv) is visible in your prompt. If you run pip install without an active virtual environment, it may fail due to system permissions.
help me fix pip install errors in my virtual environment
Create the project folder structure
Flask applications use a modular structure. You will organize your code into separate folders for authentication logic, case management logic, HTML templates, and static assets.
- Create the folder structure by running this command:
macOS
mkdir -p app/templates app/static app/auth app/cases
Windows
mkdir app/templates, app/static, app/auth, app/cases
Why this folder structure?
The app/ folder is the main package for your Flask application. Inside it, auth/ and cases/ will each become a Blueprint that groups related routes together.
The templates/ folder holds your HTML files, and static/ is for CSS, JavaScript, or images.
- Confirm the folders exist by running:
ls app/
You should see auth, cases, static, and templates listed in the output.
Missing folders in the output?
Make sure you ran the mkdir command from inside the case-manager directory. Run pwd (macOS) or cd (Windows) to check your current location. You should be inside ~/Desktop/case-manager.
help me fix my project folder structure
Your environment is locked and loaded. Next up, you will build the application factory and define the database models that power the case management system.
Build the Application Factory and Database Models
Your environment is set up and ready to go. Now it is time to build the foundation of your application.
Every Flask application needs a central place that creates and configures the app. This pattern is called an application factory. You will also need database models that define the shape of your data.
In this step, get ready to:
- Create the application factory with security extensions configured.
- Define User and Case database models with relationships.
- Set up the entry point that launches the app and initializes the database.
Create the application factory
The application factory is a function that builds and returns a fully configured Flask app. It initializes the database, login system, and CSRF protection in one place.
- Open app/__init__.py in your editor and add the following code:
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
# Create extension instances outside the factory so other modules can import them
db = SQLAlchemy()
login_manager = LoginManager()
csrf = CSRFProtect()
def create_app():
app = Flask(__name__)
# Security: load SECRET_KEY from environment, fall back to dev key
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///cases.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Initialize extensions with the app
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message_category = 'info'
csrf.init_app(app)
# Import and register blueprints
from app.auth import auth_bp
from app.cases import cases_bp
app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(cases_bp, url_prefix='/cases')
# Redirect root URL to the cases dashboard
@app.route('/')
def index():
from flask import redirect, url_for
return redirect(url_for('cases.dashboard'))
# Create database tables if they don't exist
with app.app_context():
db.create_all()
return app
What does this code do?
This file defines the create_app() factory function that builds the entire Flask application.
- SECRET_KEY protects session cookies and CSRF tokens. It loads from an environment variable in production but uses a dev fallback for now.
- SQLALCHEMY_DATABASE_URI points to a local SQLite file. No external database server needed.
- login_manager.login_view = 'auth.login' tells Flask-Login where to redirect unauthenticated users.
- Blueprints (auth_bp and cases_bp) split the app into modular pieces. You will build these in the next steps.
What is a SECRET_KEY?
Flask uses the SECRET_KEY to cryptographically sign session cookies and CSRF tokens. Without it, attackers could forge sessions or bypass form protection.
The dev fallback is fine for learning, but in production you would generate a random key and store it as an environment variable.
- Save app/__init__.py.
- Confirm the file exists by running:
ls app/__init__.py
You should see app/__init__.py printed in the output.
Don't see the file?
- Make sure you created the file inside the app/ directory, not in the project root.
- Check the filename is exactly __init__.py (two underscores on each side). This special name tells Python the folder is a package.
Help me find where my __init__.py file went
Define the database models
Database models define the structure of your data. You need two models: a User that can log in, and a Case that belongs to a user.
- Create app/models.py and add the following code:
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app import db, login_manager
class User(UserMixin, db.Model):
# Each column maps to a field in the users database table
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(20), nullable=False, default='user')
cases = db.relationship('Case', backref='author', lazy=True)
def set_password(self, password):
# Hash the password using scrypt (never store plaintext)
self.password_hash = generate_password_hash(password)
def check_password(self, password):
# Verify a password against the stored hash
return check_password_hash(self.password_hash, password)
@property
def is_admin(self):
return self.role == 'admin'
class Case(db.Model):
# Each column maps to a field in the cases database table
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=False)
status = db.Column(db.String(20), nullable=False, default='Open')
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
@login_manager.user_loader
def load_user(user_id):
# Flask-Login calls this to reload a user from their session ID
return User.query.get(int(user_id))
What do these models do?
The User model stores login credentials and a role field (user or admin). It inherits from UserMixin, which gives Flask-Login the methods it needs to manage sessions.
- set_password() hashes the password using Werkzeug's scrypt algorithm. The plaintext password is never stored.
- check_password() compares a login attempt against the stored hash.
- The Case model links to a user through user_id (a foreign key). This lets you query all cases belonging to a specific user.
- load_user() is called by Flask-Login on every request to reload the logged-in user from the database.
Why hash passwords instead of storing them directly?
If an attacker gains access to your database, hashed passwords are useless to them. Scrypt is a memory-hard hashing algorithm, which means it is expensive to brute-force even with specialized hardware.
The generate_password_hash function uses scrypt by default. Each hash includes a unique random salt, so two identical passwords produce different hashes.
- Save app/models.py.
- Confirm the file exists by running:
ls app/models.py
You should see app/models.py printed in the output.
Seeing a syntax error when you save?
- Check that the @property decorator sits on the line directly above def is_admin(self): with no blank lines in between.
- Make sure the import line reads from app import db, login_manager (not from app import db without login_manager).
Help me fix a syntax error in my models.py
Create the entry point
The entry point is the file that starts your Flask application. It calls the factory function and runs the development server.
- Create run.py in your project root (the same level as the app/ folder) and add this code:
from app import create_app
app = create_app()
if __name__ == '__main__':
with app.app_context():
from app import db
db.create_all()
app.run(debug=True)
What does this code do?
- create_app() calls your factory function to build the configured app.
- db.create_all() creates the SQLite database tables from your model definitions.
- app.run(debug=True) starts the development server with auto-reload on code changes.
- Save run.py.
- Verify all three new files are in place by running:
ls run.py app/__init__.py app/models.py
You should see all three file paths listed in the output. You will not run the app yet because the Blueprints it imports do not exist. You will create those in the next steps.
Missing one of the files?
- If run.py is missing, make sure you created it in the project root, not inside the app/ folder.
- If app/__init__.py is missing, double-check the filename uses double underscores: __init__.
Help me figure out where my files are
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file. Compare yours against these:
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
db = SQLAlchemy()
login_manager = LoginManager()
csrf = CSRFProtect()
def create_app():
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///cases.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message_category = 'info'
csrf.init_app(app)
from app.auth import auth_bp
from app.cases import cases_bp
app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(cases_bp, url_prefix='/cases')
@app.route('/')
def index():
from flask import redirect, url_for
return redirect(url_for('cases.dashboard'))
with app.app_context():
db.create_all()
return app
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app import db, login_manager
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(20), nullable=False, default='user')
cases = db.relationship('Case', backref='author', lazy=True)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
@property
def is_admin(self):
return self.role == 'admin'
class Case(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=False)
status = db.Column(db.String(20), nullable=False, default='Open')
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
from app import create_app
app = create_app()
if __name__ == '__main__':
with app.app_context():
from app import db
db.create_all()
app.run(debug=True)
Your application factory and data models are in place. Next up, you will build the authentication system so users can register, log in, and log out securely.
Implement Secure Authentication
Your database models know what a User looks like, but there's no way for anyone to actually become one yet. Without authentication, your case management system is wide open.
In this step, you'll build a complete registration and login system. Every password gets hashed before storage, every form is protected against CSRF attacks, and every route checks whether the user is allowed in.
In this step, get ready to:
- Create the auth Blueprint with register, login, and logout routes.
- Build WTForms with validation and CSRF protection.
- Create HTML templates with a shared layout, navigation, and form error handling.
Create the auth Blueprint
A Flask Blueprint groups related routes into a module. Your auth Blueprint will own everything related to registration, login, and logout.
- Create a new file called app/auth/__init__.py and add the following code:
from flask import Blueprint
auth_bp = Blueprint('auth', __name__, template_folder='templates')
from app.auth import routes
What does this code do?
Blueprint('auth', __name__, template_folder='templates') creates a named group of routes. The template_folder argument tells Flask where to find this Blueprint's HTML templates.
The import at the bottom pulls in the routes module, which registers the URL handlers on this Blueprint. It sits at the bottom to avoid circular imports.
- Save app/auth/__init__.py.
Now you'll create the route handlers that the Blueprint imports. These define what happens when a user visits the register, login, or logout URLs.
- Create a new file called app/auth/routes.py and add the following code:
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app.auth import auth_bp
from app.auth.forms import RegistrationForm, LoginForm
from app.models import User
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
# Redirect if already logged in
if current_user.is_authenticated:
return redirect(url_for('cases.dashboard'))
form = RegistrationForm()
if form.validate_on_submit():
# Create user with hashed password
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('Account created successfully! Please log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
# Redirect if already logged in
if current_user.is_authenticated:
return redirect(url_for('cases.dashboard'))
form = LoginForm()
if form.validate_on_submit():
# Look up user and verify password hash
user = User.query.filter_by(username=form.username.data).first()
if user and user.check_password(form.password.data):
login_user(user)
next_page = request.args.get('next')
flash('Logged in successfully!', 'success')
return redirect(next_page or url_for('cases.dashboard'))
else:
flash('Invalid username or password.', 'danger')
return render_template('auth/login.html', form=form)
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('auth.login'))
What does this code do?
- The register route creates a new User, calls set_password() to hash the password with scrypt, then saves it to the database.
- The login route looks up the user by username and calls check_password() to verify the hash. If it matches, login_user() starts a session.
- The logout route uses @login_required so only authenticated users can log out. It ends the session and redirects to login.
- The next_page logic remembers where the user was trying to go before being redirected to login, so they land on that page after authenticating.
Why hash passwords instead of storing them directly?
If your database is ever compromised, hashed passwords are unreadable. Werkzeug's generate_password_hash uses scrypt by default, which is memory-hard. This makes brute-force attacks extremely expensive even with specialized hardware.
- Save app/auth/routes.py.
Seeing an import error?
The routes file imports RegistrationForm and LoginForm from app.auth.forms, which you haven't created yet. That's expected. You'll create the forms file in the next substep.
Help me debug my auth routes import error.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is app/auth/__init__.py:
from flask import Blueprint
auth_bp = Blueprint('auth', __name__, template_folder='templates')
from app.auth import routes
Here is app/auth/routes.py:
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app.auth import auth_bp
from app.auth.forms import RegistrationForm, LoginForm
from app.models import User
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('cases.dashboard'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('Account created successfully! Please log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('cases.dashboard'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user and user.check_password(form.password.data):
login_user(user)
next_page = request.args.get('next')
flash('Logged in successfully!', 'success')
return redirect(next_page or url_for('cases.dashboard'))
else:
flash('Invalid username or password.', 'danger')
return render_template('auth/login.html', form=form)
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('auth.login'))
Create the authentication forms
Flask-WTF forms handle two things at once: they validate user input on the server, and they automatically inject a CSRF token into every form to prevent cross-site request forgery.
- Create a new file called app/auth/forms.py and add the following code:
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError
from app.models import User
class RegistrationForm(FlaskForm):
# Each field defines its label and validation rules
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=80)])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
# Custom validators run automatically when form.validate_on_submit() is called
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user:
raise ValidationError('Username already taken. Please choose a different one.')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user:
raise ValidationError('Email already registered. Please use a different one.')
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Login')
What does this code do?
- RegistrationForm requires a username (3-80 characters), a valid email, and a password (minimum 8 characters) that matches the confirm field.
- validate_username and validate_email are custom validators. WTForms automatically calls any method named validate_<fieldname> during validation. These check the database for duplicates before allowing registration.
- LoginForm is simpler. It only needs a username and password since there's no data to create.
- Both forms inherit from FlaskForm, which adds CSRF protection automatically. No extra code needed.
- Save app/auth/forms.py.
You won't see a change yet. The forms need HTML templates to render in the browser, which you'll create next.
Seeing a validator import error?
Make sure you imported ValidationError from wtforms.validators, not from wtforms directly. Also confirm that Email is in the validators import line.
Help me fix my WTForms import error.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError
from app.models import User
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=80)])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user:
raise ValidationError('Username already taken. Please choose a different one.')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user:
raise ValidationError('Email already registered. Please use a different one.')
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Login')
Create the HTML templates
Flask uses Jinja2 templating to render HTML. You'll create a base template with shared layout (navigation, styling, flash messages), then two child templates that extend it for login and registration.
- Create the template directories by running this command:
mkdir -p app/templates/auth
- Confirm the directories exist by running:
ls app/templates/auth
You should see an empty directory with no output (no files yet).
- Create a new file called app/templates/base.html and add 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 %}Case Manager{% endblock %}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
nav { background: #1a1a2e; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; }
nav a { color: #e0e0e0; text-decoration: none; margin-left: 1rem; }
nav a:hover { color: #fff; }
nav .brand { color: #fff; font-weight: bold; font-size: 1.2rem; }
.container { max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
.flash { padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
.flash.success { background: #d4edda; color: #155724; }
.flash.danger { background: #f8d7da; color: #721c24; }
.flash.info { background: #d1ecf1; color: #0c5460; }
.card { background: #fff; border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.25rem; font-weight: 500; }
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; }
.form-group textarea { min-height: 100px; resize: vertical; }
.btn { display: inline-block; padding: 0.5rem 1.5rem; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; text-decoration: none; }
.btn-primary { background: #1a1a2e; color: #fff; }
.btn-primary:hover { background: #16213e; }
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.875rem; }
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.badge-open { background: #fff3cd; color: #856404; }
.badge-in-progress { background: #cce5ff; color: #004085; }
.badge-resolved { background: #d4edda; color: #155724; }
.error { color: #dc3545; font-size: 0.875rem; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; }
th { font-weight: 600; }
</style>
</head>
<body>
<!-- Navigation bar shows different links based on login state -->
<nav>
<span class="brand">Case Manager</span>
<div>
{% if current_user.is_authenticated %}
<a href="{{ url_for('cases.dashboard') }}">Dashboard</a>
<a href="{{ url_for('cases.create') }}">New Case</a>
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a>
{% else %}
<a href="{{ url_for('auth.login') }}">Login</a>
<a href="{{ url_for('auth.register') }}">Register</a>
{% endif %}
</div>
</nav>
<div class="container">
<!-- Display flash messages (success, error, info) -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</body>
</html>
What does this template do?
- {% block title %} and {% block content %} are placeholders that child templates fill in with their own content.
- The navigation bar checks current_user.is_authenticated to show different links depending on whether someone is logged in.
- The flash messages section displays temporary feedback (like "Account created successfully!") that your routes send with the flash() function.
- Save app/templates/base.html.
Now create the registration page that extends this base layout.
- Create a new file called app/templates/auth/register.html and add the following code:
{% extends "base.html" %}
{% block title %}Register - Case Manager{% endblock %}
{% block content %}
<div class="card">
<h2>Create Account</h2>
<br>
<form method="POST" action="{{ url_for('auth.register') }}">
<!-- hidden_tag() inserts the CSRF token as a hidden field -->
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.username.label }}
{{ form.username(placeholder="Choose a username") }}
{% for error in form.username.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.email.label }}
{{ form.email(placeholder="your@email.com") }}
{% for error in form.email.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.password.label }}
{{ form.password(placeholder="Minimum 8 characters") }}
{% for error in form.password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.confirm_password.label }}
{{ form.confirm_password(placeholder="Re-enter password") }}
{% for error in form.confirm_password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
<br>
<p>Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a></p>
</div>
{% endblock %}
What does hidden_tag() do?
{{ form.hidden_tag() }} injects a hidden input field containing a unique CSRF token. When the form is submitted, Flask-WTF checks that this token matches the one it generated. If an attacker tries to submit a forged form from another site, the token won't match and the request is rejected.
- Save app/templates/auth/register.html.
Finally, create the login page. It follows the same pattern but with fewer fields.
- Create a new file called app/templates/auth/login.html and add the following code:
{% extends "base.html" %}
{% block title %}Login - Case Manager{% endblock %}
{% block content %}
<div class="card">
<h2>Login</h2>
<br>
<form method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.username.label }}
{{ form.username(placeholder="Your username") }}
{% for error in form.username.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.password.label }}
{{ form.password(placeholder="Your password") }}
{% for error in form.password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
<br>
<p>Need an account? <a href="{{ url_for('auth.register') }}">Register here</a></p>
</div>
{% endblock %}
- Save app/templates/auth/login.html.
You now have all the files needed for the auth system. Verify by running the app:
- Start the Flask development server by running:
python run.py
- Open your browser and navigate to http://127.0.0.1:5000/auth/register.
You should see a registration form with fields for username, email, password, and confirm password. The page has a dark navigation bar at the top with "Login" and "Register" links.
- Press Ctrl+C in your terminal to stop the server.
Seeing a TemplateNotFound error?
Make sure your base.html file is directly inside app/templates/ (not inside app/templates/auth/). The register.html and login.html files go inside app/templates/auth/.
Help me fix my Flask TemplateNotFound error.
✔️ Awesome, I've got everything!
Great. Make sure all template files are saved.
ⓧ I'd like to double check the full code
Here is app/templates/base.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Case Manager{% endblock %}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
nav { background: #1a1a2e; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; }
nav a { color: #e0e0e0; text-decoration: none; margin-left: 1rem; }
nav a:hover { color: #fff; }
nav .brand { color: #fff; font-weight: bold; font-size: 1.2rem; }
.container { max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
.flash { padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
.flash.success { background: #d4edda; color: #155724; }
.flash.danger { background: #f8d7da; color: #721c24; }
.flash.info { background: #d1ecf1; color: #0c5460; }
.card { background: #fff; border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.25rem; font-weight: 500; }
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; }
.form-group textarea { min-height: 100px; resize: vertical; }
.btn { display: inline-block; padding: 0.5rem 1.5rem; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; text-decoration: none; }
.btn-primary { background: #1a1a2e; color: #fff; }
.btn-primary:hover { background: #16213e; }
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.875rem; }
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.badge-open { background: #fff3cd; color: #856404; }
.badge-in-progress { background: #cce5ff; color: #004085; }
.badge-resolved { background: #d4edda; color: #155724; }
.error { color: #dc3545; font-size: 0.875rem; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; }
th { font-weight: 600; }
</style>
</head>
<body>
<nav>
<span class="brand">Case Manager</span>
<div>
{% if current_user.is_authenticated %}
<a href="{{ url_for('cases.dashboard') }}">Dashboard</a>
<a href="{{ url_for('cases.create') }}">New Case</a>
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a>
{% else %}
<a href="{{ url_for('auth.login') }}">Login</a>
<a href="{{ url_for('auth.register') }}">Register</a>
{% endif %}
</div>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</body>
</html>
Here is app/templates/auth/register.html:
{% extends "base.html" %}
{% block title %}Register - Case Manager{% endblock %}
{% block content %}
<div class="card">
<h2>Create Account</h2>
<br>
<form method="POST" action="{{ url_for('auth.register') }}">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.username.label }}
{{ form.username(placeholder="Choose a username") }}
{% for error in form.username.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.email.label }}
{{ form.email(placeholder="your@email.com") }}
{% for error in form.email.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.password.label }}
{{ form.password(placeholder="Minimum 8 characters") }}
{% for error in form.password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.confirm_password.label }}
{{ form.confirm_password(placeholder="Re-enter password") }}
{% for error in form.confirm_password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
<br>
<p>Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a></p>
</div>
{% endblock %}
Here is app/templates/auth/login.html:
{% extends "base.html" %}
{% block title %}Login - Case Manager{% endblock %}
{% block content %}
<div class="card">
<h2>Login</h2>
<br>
<form method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.username.label }}
{{ form.username(placeholder="Your username") }}
{% for error in form.username.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.password.label }}
{{ form.password(placeholder="Your password") }}
{% for error in form.password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
<br>
<p>Need an account? <a href="{{ url_for('auth.register') }}">Register here</a></p>
</div>
{% endblock %}
Your authentication system is fully wired up. Users can register with validated forms, log in with password verification, and log out. Next up, you'll build the case management routes so logged-in users can actually create and track their cases.
Build the Case Management System
Your authentication system is working. Users can register and log in, but they have nothing to do once they're inside the app.
In this step, you'll build the core case management functionality. This means creating routes that let users submit support cases, a dashboard to view them, and authorization logic that controls who sees what.
In this step, get ready to:
- Create the cases Blueprint and define all CRUD routes.
- Build case forms with CSRF protection and authorization checks.
- Design HTML templates for the dashboard, case creation, and detail views.
Register the cases Blueprint and define routes
Just like the auth module, the cases module gets its own Blueprint. This keeps authentication logic separate from business logic.
- Create a new file called app/cases/__init__.py and add the following code:
from flask import Blueprint
cases_bp = Blueprint('cases', __name__, template_folder='templates')
from app.cases import routes
What does this code do?
This creates a Blueprint named cases and tells Flask where to find its templates. The import at the bottom loads the route handlers once the Blueprint is defined.
- Save app/cases/__init__.py.
Now you need the routes file that handles all case-related URL requests. This is the heart of the case management system.
- Create a new file called app/cases/routes.py and add the following code:
from flask import render_template, redirect, url_for, flash, abort
from flask_login import login_required, current_user
from app import db
from app.cases import cases_bp
from app.cases.forms import CreateCaseForm, UpdateStatusForm
from app.models import Case
@cases_bp.route('/')
@login_required
def dashboard():
# Admins see all cases, regular users see only their own
if current_user.is_admin:
cases = Case.query.order_by(Case.created_at.desc()).all()
else:
cases = Case.query.filter_by(user_id=current_user.id).order_by(Case.created_at.desc()).all()
return render_template('cases/dashboard.html', cases=cases)
@cases_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create():
form = CreateCaseForm()
if form.validate_on_submit():
case = Case(
title=form.title.data,
description=form.description.data,
user_id=current_user.id
)
db.session.add(case)
db.session.commit()
flash('Case created successfully!', 'success')
return redirect(url_for('cases.dashboard'))
return render_template('cases/create.html', form=form)
@cases_bp.route('/<int:case_id>')
@login_required
def detail(case_id):
case = Case.query.get_or_404(case_id)
# Authorization: only the case owner or an admin can view
if not current_user.is_admin and case.user_id != current_user.id:
abort(403)
form = UpdateStatusForm(obj=case)
return render_template('cases/detail.html', case=case, form=form)
@cases_bp.route('/<int:case_id>/update-status', methods=['POST'])
@login_required
def update_status(case_id):
# Only admins can change case status
if not current_user.is_admin:
abort(403)
case = Case.query.get_or_404(case_id)
form = UpdateStatusForm()
if form.validate_on_submit():
case.status = form.status.data
db.session.commit()
flash('Case status updated.', 'success')
return redirect(url_for('cases.detail', case_id=case.id))
What does this code do?
Each route uses the @login_required decorator, which redirects unauthenticated users to the login page automatically.
- dashboard() checks current_user.is_admin to decide whether to show all cases or just the user's own.
- detail() uses abort(403) to block unauthorized access. If a regular user tries to view someone else's case, they get a Forbidden error.
- update_status() is admin-only. Regular users cannot change case statuses.
- Save app/cases/routes.py.
- You won't see a change yet. The routes reference forms that don't exist yet, which we'll create next.
Create the case management forms
The routes you just wrote import two form classes. These forms handle validation and provide automatic CSRF protection for every submission.
- Create a new file called app/cases/forms.py and add the following code:
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SelectField, SubmitField
from wtforms.validators import DataRequired, Length
class CreateCaseForm(FlaskForm):
# Form for users to submit a new support case
title = StringField('Title', validators=[DataRequired(), Length(max=200)])
description = TextAreaField('Description', validators=[DataRequired()])
submit = SubmitField('Create Case')
class UpdateStatusForm(FlaskForm):
# Form for admins to change case status
status = SelectField('Status', choices=[('Open', 'Open'), ('In Progress', 'In Progress'), ('Resolved', 'Resolved')], validators=[DataRequired()])
submit = SubmitField('Update Status')
What does this code do?
CreateCaseForm lets users enter a title and description for their support case. UpdateStatusForm gives admins a dropdown to move cases between Open, In Progress, and Resolved.
Both forms inherit from FlaskForm, which automatically generates and validates a CSRF token on every submission. No extra code needed.
- Save app/cases/forms.py.
- You won't see a change yet. The next substep creates the HTML templates these routes render.
Why is authorization checked in the routes?
Role-based access control (RBAC) works at the route level because that's where a request enters your application. By checking permissions before any database query or template render, you prevent unauthorized data from ever being loaded.
Build the case templates
Your routes reference three templates that don't exist yet. You need a dashboard, a create form, and a detail view.
- Create the templates directory by running this command:
mkdir -p app/templates/cases
- Create a new file called app/templates/cases/dashboard.html and add the following code:
{% extends "base.html" %}
{% block title %}Dashboard - Case Manager{% endblock %}
{% block content %}
<div class="card">
<h2>{% if current_user.is_admin %}All Cases (Admin){% else %}My Cases{% endif %}</h2>
<br>
{% if cases %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Status</th>
<th>Created</th>
{% if current_user.is_admin %}<th>Author</th>{% endif %}
</tr>
</thead>
<tbody>
{% for case in cases %}
<tr>
<td>{{ case.id }}</td>
<td><a href="{{ url_for('cases.detail', case_id=case.id) }}">{{ case.title }}</a></td>
<td>
{% if case.status == 'Open' %}
<span class="badge badge-open">Open</span>
{% elif case.status == 'In Progress' %}
<span class="badge badge-in-progress">In Progress</span>
{% else %}
<span class="badge badge-resolved">Resolved</span>
{% endif %}
</td>
<td>{{ case.created_at.strftime('%Y-%m-%d') }}</td>
{% if current_user.is_admin %}<td>{{ case.author.username }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No cases yet. <a href="{{ url_for('cases.create') }}">Create your first case</a>.</p>
{% endif %}
</div>
{% endblock %}
What does this template do?
The dashboard displays cases in a table with color-coded status badges. It conditionally shows an Author column only when the logged-in user is an admin. If no cases exist, it shows a link to create one.
- Save app/templates/cases/dashboard.html.
- Create a new file called app/templates/cases/create.html and add the following code:
{% extends "base.html" %}
{% block title %}Create Case - Case Manager{% endblock %}
{% block content %}
<div class="card">
<h2>Create New Case</h2>
<br>
<form method="POST" action="{{ url_for('cases.create') }}">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.title.label }}
{{ form.title(placeholder="Brief description of the issue") }}
{% for error in form.title.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.description.label }}
{{ form.description(placeholder="Provide details about the case...") }}
{% for error in form.description.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
</div>
{% endblock %}
What does this template do?
This renders the case creation form with a title field and a description textarea. The {{ form.hidden_tag() }} line inserts the CSRF token as a hidden field, protecting the form from cross-site request forgery attacks.
- Save app/templates/cases/create.html.
- Create a new file called app/templates/cases/detail.html and add the following code:
{% extends "base.html" %}
{% block title %}Case #{{ case.id }} - Case Manager{% endblock %}
{% block content %}
<div class="card">
<h2>{{ case.title }}</h2>
<p>
{% if case.status == 'Open' %}
<span class="badge badge-open">Open</span>
{% elif case.status == 'In Progress' %}
<span class="badge badge-in-progress">In Progress</span>
{% else %}
<span class="badge badge-resolved">Resolved</span>
{% endif %}
— Created {{ case.created_at.strftime('%Y-%m-%d %H:%M') }} by {{ case.author.username }}
</p>
<br>
<p>{{ case.description }}</p>
{% if current_user.is_admin %}
<br>
<hr>
<h3>Update Status</h3>
<form method="POST" action="{{ url_for('cases.update_status', case_id=case.id) }}">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.status.label }}
{{ form.status() }}
</div>
{{ form.submit(class="btn btn-primary btn-sm") }}
</form>
{% endif %}
</div>
<br>
<a href="{{ url_for('cases.dashboard') }}">← Back to Dashboard</a>
{% endblock %}
What does this template do?
The detail page shows a single case's full information. The status update form only appears if the logged-in user is an admin. Regular users see the case details but cannot change the status.
- Save app/templates/cases/detail.html.
- Verify the app starts without errors by running this command:
flask run
- You should see output showing Running on http://127.0.0.1:5000 with no error messages.
- Open http://127.0.0.1:5000 in your browser. You should be redirected to the login page since you're not authenticated yet.
- Press Ctrl+C in the terminal to stop the server for now.
Seeing an ImportError or ModuleNotFoundError?
- Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt.
- Check that app/cases/__init__.py exists. Without it, Python doesn't recognize app/cases/ as a package.
- Verify the filename forms.py is inside the app/cases/ directory, not in app/ or the project root.
Why won't my Flask app start?
✔️ Awesome, I've got everything!
Great. Double check you've saved all six new files before moving on.
ⓧ I'd like to double check the full code
Here are all three Python files for the cases module:
from flask import Blueprint
cases_bp = Blueprint('cases', __name__, template_folder='templates')
from app.cases import routes
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SelectField, SubmitField
from wtforms.validators import DataRequired, Length
class CreateCaseForm(FlaskForm):
title = StringField('Title', validators=[DataRequired(), Length(max=200)])
description = TextAreaField('Description', validators=[DataRequired()])
submit = SubmitField('Create Case')
class UpdateStatusForm(FlaskForm):
status = SelectField('Status', choices=[('Open', 'Open'), ('In Progress', 'In Progress'), ('Resolved', 'Resolved')], validators=[DataRequired()])
submit = SubmitField('Update Status')
from flask import render_template, redirect, url_for, flash, abort
from flask_login import login_required, current_user
from app import db
from app.cases import cases_bp
from app.cases.forms import CreateCaseForm, UpdateStatusForm
from app.models import Case
@cases_bp.route('/')
@login_required
def dashboard():
if current_user.is_admin:
cases = Case.query.order_by(Case.created_at.desc()).all()
else:
cases = Case.query.filter_by(user_id=current_user.id).order_by(Case.created_at.desc()).all()
return render_template('cases/dashboard.html', cases=cases)
@cases_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create():
form = CreateCaseForm()
if form.validate_on_submit():
case = Case(
title=form.title.data,
description=form.description.data,
user_id=current_user.id
)
db.session.add(case)
db.session.commit()
flash('Case created successfully!', 'success')
return redirect(url_for('cases.dashboard'))
return render_template('cases/create.html', form=form)
@cases_bp.route('/<int:case_id>')
@login_required
def detail(case_id):
case = Case.query.get_or_404(case_id)
if not current_user.is_admin and case.user_id != current_user.id:
abort(403)
form = UpdateStatusForm(obj=case)
return render_template('cases/detail.html', case=case, form=form)
@cases_bp.route('/<int:case_id>/update-status', methods=['POST'])
@login_required
def update_status(case_id):
if not current_user.is_admin:
abort(403)
case = Case.query.get_or_404(case_id)
form = UpdateStatusForm()
if form.validate_on_submit():
case.status = form.status.data
db.session.commit()
flash('Case status updated.', 'success')
return redirect(url_for('cases.detail', case_id=case.id))
And here are the three template files:
{% extends "base.html" %}
{% block title %}Dashboard - Case Manager{% endblock %}
{% block content %}
<div class="card">
<h2>{% if current_user.is_admin %}All Cases (Admin){% else %}My Cases{% endif %}</h2>
<br>
{% if cases %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Status</th>
<th>Created</th>
{% if current_user.is_admin %}<th>Author</th>{% endif %}
</tr>
</thead>
<tbody>
{% for case in cases %}
<tr>
<td>{{ case.id }}</td>
<td><a href="{{ url_for('cases.detail', case_id=case.id) }}">{{ case.title }}</a></td>
<td>
{% if case.status == 'Open' %}
<span class="badge badge-open">Open</span>
{% elif case.status == 'In Progress' %}
<span class="badge badge-in-progress">In Progress</span>
{% else %}
<span class="badge badge-resolved">Resolved</span>
{% endif %}
</td>
<td>{{ case.created_at.strftime('%Y-%m-%d') }}</td>
{% if current_user.is_admin %}<td>{{ case.author.username }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No cases yet. <a href="{{ url_for('cases.create') }}">Create your first case</a>.</p>
{% endif %}
</div>
{% endblock %}
{% extends "base.html" %}
{% block title %}Create Case - Case Manager{% endblock %}
{% block content %}
<div class="card">
<h2>Create New Case</h2>
<br>
<form method="POST" action="{{ url_for('cases.create') }}">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.title.label }}
{{ form.title(placeholder="Brief description of the issue") }}
{% for error in form.title.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<div class="form-group">
{{ form.description.label }}
{{ form.description(placeholder="Provide details about the case...") }}
{% for error in form.description.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
</div>
{% endblock %}
{% extends "base.html" %}
{% block title %}Case #{{ case.id }} - Case Manager{% endblock %}
{% block content %}
<div class="card">
<h2>{{ case.title }}</h2>
<p>
{% if case.status == 'Open' %}
<span class="badge badge-open">Open</span>
{% elif case.status == 'In Progress' %}
<span class="badge badge-in-progress">In Progress</span>
{% else %}
<span class="badge badge-resolved">Resolved</span>
{% endif %}
— Created {{ case.created_at.strftime('%Y-%m-%d %H:%M') }} by {{ case.author.username }}
</p>
<br>
<p>{{ case.description }}</p>
{% if current_user.is_admin %}
<br>
<hr>
<h3>Update Status</h3>
<form method="POST" action="{{ url_for('cases.update_status', case_id=case.id) }}">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.status.label }}
{{ form.status() }}
</div>
{{ form.submit(class="btn btn-primary btn-sm") }}
</form>
{% endif %}
</div>
<br>
<a href="{{ url_for('cases.dashboard') }}">← Back to Dashboard</a>
{% endblock %}
Your case management system is fully built. Next up, you'll run the app, create user accounts, and verify that the security patterns actually work.
Test Your Secure Application
Your application code is complete. Every model, route, form, and template is in place.
Now it's time to start the server and verify that authentication, case management, and security protections all work as expected. You'll register users, promote one to admin, create cases, and then audit the underlying database to confirm your passwords are hashed and your CSRF tokens are present.
In this step, get ready to:
- Run the Flask development server and register two user accounts.
- Test case creation and role-based dashboard access.
- Verify CSRF protection, password hashing, and role-based authorization.
Run the app and set up user accounts
Before you can test anything, you need the development server running and at least two user accounts: one regular user and one admin.
- Start the Flask development server by running this command in your terminal:
flask run
You should see output indicating the server is running on http://127.0.0.1:5000.
Server not starting?
Make sure your virtual environment is active (you should see (venv) in your terminal prompt). Also confirm you are in the project root directory where run.py and the app/ folder are located.
help me debug why my Flask server won't start
- Open your web browser and navigate to http://127.0.0.1:5000.
- You'll be redirected to the login page. Click the Register here link.
- Fill in the registration form with your first user's details:
- Username: testuser
- Email: test@example.com
- Password: password123
- Confirm Password: password123
- Click Register.
- You should see a green flash message saying the account was created. You'll be redirected to the login page.
- Click Register here again and create a second account:
- Username: adminuser
- Email: admin@example.com
- Password: password123
- Confirm Password: password123
- Click Register.
Both accounts are now registered with the default role of user. You need to promote adminuser to the admin role directly in the database.
- Stop the Flask server by pressing Ctrl+C in your terminal.
- Update the admin user's role by running this command:
sqlite3 instance/cases.db "UPDATE user SET role='admin' WHERE username='adminuser';"
What does this command do?
The sqlite3 command opens your database file directly. The SQL statement sets the role column to admin for the user named adminuser. This simulates what a real system would do with an admin setup script or management command.
sqlite3 command not found?
On Windows, the sqlite3 CLI may not be installed. You can use Python's built-in SQLite support instead by running this alternative:
python -c "import sqlite3; conn = sqlite3.connect('instance/cases.db'); conn.execute(\"UPDATE user SET role='admin' WHERE username='adminuser'\"); conn.commit(); conn.close(); print('Done')"
help me update my database user role to admin
- Restart the Flask server by running:
flask run
- Return to your browser and log in as testuser with password password123.
- You should see a green flash message and the My Cases dashboard.
- Click Logout in the navigation bar.
- You should see a blue flash message confirming you've been logged out.
Why test login and logout separately?
Login verifies that Flask-Login creates a session cookie correctly. Logout verifies the session is destroyed. If either fails, the rest of your access control is meaningless.
Create cases and verify role-based access
With both accounts working, you can test the core case management workflow and confirm that regular users and admins see different things on the dashboard.
- Log in as testuser.
- Click New Case in the navigation bar.
- Enter Login page broken on mobile as the title.
- Enter The login form does not render correctly on screens smaller than 400px. as the description.
- Click Create Case.
You should see a green flash message and the case appears on your dashboard with an Open badge.
- Create a second case with title Password reset email not sending and any description you like.
- Create a third case with title Dashboard loads slowly and any description.
Your dashboard should now show three cases, all with the Open status badge.
- Click Logout in the navigation bar.
- Log in as adminuser with password password123.
The dashboard heading should now say All Cases (Admin) and display all three cases created by testuser along with an Author column.
- Click on any case title to open the detail view.
- As admin, you should see an Update Status form at the bottom of the case detail page.
- Change the status dropdown to In Progress and click Update Status.
- You should see a green flash message and the badge updates to a blue In Progress badge.
What is role-based access control?
Role-based access control (RBAC) restricts system access based on the user's assigned role. In your app, regular users only see their own cases while admins see everything. This pattern is used in every multi-user production application.
Verify your security features
The app looks like it works, but how do you know the security layers are actually doing their job? You'll now inspect three critical protections directly.
- While logged in as adminuser, navigate to the New Case page.
- Right-click anywhere on the page and select View Page Source (or press Cmd+Option+U on macOS / Ctrl+U on Windows).
- Search for csrf_token in the source. You should find a hidden input field with a long random token value.
What is a CSRF token?
A CSRF token is a random value embedded in every form that proves the submission came from your site. Without it, an attacker could trick a logged-in user into submitting a form from a malicious page.
Flask-WTF generates a unique token per session and validates it on every POST request. If the token is missing or doesn't match, the request is rejected with a 400 error.
Next, verify that passwords are stored securely in the database.
- Stop the Flask server by pressing Ctrl+C in your terminal.
- Query the user table to inspect password storage by running:
sqlite3 instance/cases.db "SELECT username, password_hash FROM user;"
You should see output where the password_hash column contains long strings starting with scrypt: followed by random characters. The actual password password123 appears nowhere in the output.
Why scrypt?
Werkzeug's generate_password_hash uses scrypt by default. Scrypt is a memory-hard hashing algorithm, which means it requires a large amount of RAM to compute. This makes brute-force attacks with GPUs or ASICs extremely expensive compared to simpler algorithms.
Finally, verify that role-based authorization actually blocks unauthorized access.
- Restart the Flask server by running:
flask run
- Log in as testuser (the regular user).
- Click on one of your cases to open the detail view.
- Notice that the Update Status form does not appear. The template hides admin controls from regular users.
- Now test the server-side protection. In your browser's address bar, manually type:
http://127.0.0.1:5000/cases/1/update-status
- Press Enter. You should see a Method Not Allowed error because the route only accepts POST requests.
Why does this matter?
Hiding a button in the UI is not real security. A determined user could craft a POST request directly. Your update_status route checks if not current_user.is_admin: abort(403) before doing anything. This server-side check is the actual authorization gate.
The template-level hiding is just a convenience so regular users don't see controls they can't use.
Seeing unexpected errors?
If you see an import error or database error when starting the server, make sure you saved all your files and that your virtual environment is active. Check that the instance/cases.db file exists in your project folder.
help me troubleshoot errors when running my Flask case management app
Secret mission
Add Case Search and Filtering
Your case management system works, but as the number of cases grows, scrolling through a long list gets painful. Can you add search and filtering so admins can instantly find the cases that need attention?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Flask development server (running process in your terminal).
- Project folder containing all application code, templates, and the SQLite database (cases.db).
- Python virtual environment (venv) inside the project folder.
Keep everything running
No action needed. Choose this if you are still actively building or want to experiment further with the case management system.
- Your Flask server continues running at http://127.0.0.1:5000.
- Your SQLite database retains all users and cases you created.
- Your virtual environment stays active in the terminal.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your 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 development server.
- Deactivate your virtual environment by running:
deactivate
When you are ready to resume, navigate back to your project folder, reactivate the virtual environment, and start the server again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your application code, database, and virtual environment permanently.
- In the terminal where Flask is running, press Ctrl+C to stop the development server.
- Deactivate your virtual environment by running:
deactivate
- Navigate to your Desktop and delete the project folder by running:
macOS
cd ~/Desktop
rm -rf secure-case-management
Windows
cd ~\Desktop
rmdir /s /q secure-case-management
- Confirm the folder is gone by running:
ls
You should no longer see your project folder in the output.
Nice Work!
Nice Work!
Nice work! You just built a secure case management system from scratch using Flask, complete with user authentication, role-based access control, and CSRF-protected forms.
You learned how to:
- Build a modular Flask application using Blueprints to separate authentication from business logic.
- Implement secure user authentication with password hashing (scrypt via Werkzeug) and session management.
- Add role-based access control and CSRF protection to secure all forms and restrict sensitive routes to authorized users.
- Build search and filtering into the admin dashboard so cases can be found and resolved quickly.
Ready to quiz yourself?