Every web application that handles user data needs authentication and authorization. Authentication answers the question “Who are you?” while authorization answers “What are you allowed to do?” Getting these wrong can expose user data, enable account takeovers, and destroy user trust. Getting them right is non-negotiable.
In this tutorial, we will build authentication and authorization into Flask applications from the ground up. We will cover password hashing, session-based auth, token-based auth with JWT, role-based access control, OAuth2 social login, and a complete practical example that ties everything together.
| Concept | Question | Example |
|---|---|---|
| Authentication | Who are you? | Login with username and password |
| Authorization | What can you do? | Only admins can delete users |
Authentication always comes first. You cannot determine what someone is allowed to do until you know who they are. Flask gives you the building blocks; libraries like Flask-Login and Flask-JWT-Extended give you battle-tested implementations.
| Approach | Best For | Mechanism |
|---|---|---|
| Session-based | Server-rendered web apps | Cookie with session ID, server stores session data |
| Token-based (JWT) | APIs, SPAs, mobile apps | Signed token sent in Authorization header |
| OAuth2 / Social Login | Third-party identity providers | Delegated auth via Google, GitHub, etc. |
| API Keys | Service-to-service communication | Static key in header or query param |
Never store plaintext passwords. If your database is compromised, every user account is instantly exposed. Password hashing is a one-way transformation: you can verify a password against a hash, but you cannot reverse the hash to recover the original password.
Flask ships with Werkzeug, which provides generate_password_hash and check_password_hash. These use PBKDF2 by default, which is a solid choice for most applications.
from werkzeug.security import generate_password_hash, check_password_hash
# Hash a password (uses pbkdf2:sha256 by default)
password = "my_secure_password_123"
hashed = generate_password_hash(password)
print(hashed)
# pbkdf2:sha256:600000$salt$hash...
# Verify a password against the hash
print(check_password_hash(hashed, "my_secure_password_123")) # True
print(check_password_hash(hashed, "wrong_password")) # False
# Customize the method and salt length
hashed_custom = generate_password_hash(
password,
method="pbkdf2:sha256:260000",
salt_length=16
)
Key points about Werkzeug hashing:
generate_password_hash produces a different result because of random saltcheck_password_hash extracts the salt from the stored hash and recomputesBcrypt is another popular hashing algorithm specifically designed for passwords. It has a built-in work factor that makes it progressively harder to crack as hardware improves.
pip install flask-bcrypt
from flask import Flask
from flask_bcrypt import Bcrypt
app = Flask(__name__)
bcrypt = Bcrypt(app)
# Hash a password
password = "my_secure_password_123"
hashed = bcrypt.generate_password_hash(password).decode("utf-8")
print(hashed)
# $2b$12$randomsalt...
# Verify
print(bcrypt.check_password_hash(hashed, "my_secure_password_123")) # True
print(bcrypt.check_password_hash(hashed, "wrong_password")) # False
| Feature | Werkzeug (PBKDF2) | Bcrypt |
|---|---|---|
| Built into Flask | Yes | No (requires flask-bcrypt) |
| Algorithm | PBKDF2-SHA256 | Blowfish-based |
| Max password length | No limit | 72 bytes |
| Industry adoption | High | Very high |
| Recommendation | Good default | Good if team prefers bcrypt |
Both are excellent choices. Use Werkzeug’s built-in hashing unless your team has a specific reason to prefer bcrypt.
Session-based authentication is the traditional approach for server-rendered web applications. The server creates a session after login, stores session data server-side, and sends a session ID cookie to the client. On every subsequent request, the browser automatically sends the cookie, and the server looks up the session.
Flask uses client-side sessions by default. The session data is serialized, cryptographically signed with your secret_key, and stored in a cookie. The server does not need to store anything. The signature prevents tampering, but the data is not encrypted — users can read (but not modify) session contents.
from flask import Flask, session
app = Flask(__name__)
# CRITICAL: Set a strong secret key
# In production, load from environment variable
app.secret_key = "your-secret-key-change-this-in-production"
# Better: load from environment
import os
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-fallback-key")
Important: If someone obtains your secret_key, they can forge session cookies and impersonate any user. Never hardcode it in source code for production. Use environment variables or a secrets manager.
Let us build a minimal session-based auth system without any extensions.
from flask import Flask, session, request, redirect, url_for, render_template_string
from werkzeug.security import generate_password_hash, check_password_hash
from functools import wraps
import os
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-secret-key")
# Simulated user database
users_db = {
"alice": {
"password_hash": generate_password_hash("alice123"),
"email": "alice@example.com",
"role": "admin"
},
"bob": {
"password_hash": generate_password_hash("bob456"),
"email": "bob@example.com",
"role": "user"
}
}
def login_required(f):
"""Custom decorator to protect routes."""
@wraps(f)
def decorated_function(*args, **kwargs):
if "username" not in session:
return redirect(url_for("login"))
return f(*args, **kwargs)
return decorated_function
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user = users_db.get(username)
if user and check_password_hash(user["password_hash"], password):
# Create session
session["username"] = username
session["role"] = user["role"]
return redirect(url_for("dashboard"))
else:
return render_template_string(LOGIN_TEMPLATE, error="Invalid credentials")
return render_template_string(LOGIN_TEMPLATE, error=None)
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.route("/dashboard")
@login_required
def dashboard():
return f"<h1>Welcome, {session['username']}!</h1><p>Role: {session['role']}</p><a href='/logout'>Logout</a>"
LOGIN_TEMPLATE = """
<h1>Login</h1>
{% if error %}<p style="color:red">{{ error }}</p>{% endif %}
<form method="post">
<input name="username" placeholder="Username" required><br>
<input name="password" type="password" placeholder="Password" required><br>
<button type="submit">Login</button>
</form>
"""
if __name__ == "__main__":
app.run(debug=True)
Flask provides several configuration options to control session behavior.
from datetime import timedelta
app.config.update(
# Session lifetime (default: browser session, until tab closes)
PERMANENT_SESSION_LIFETIME=timedelta(hours=1),
# Cookie settings
SESSION_COOKIE_SECURE=True, # Only send over HTTPS
SESSION_COOKIE_HTTPONLY=True, # JavaScript cannot access the cookie
SESSION_COOKIE_SAMESITE="Lax", # CSRF protection
SESSION_COOKIE_NAME="my_session", # Custom cookie name
)
# Make sessions permanent (respect PERMANENT_SESSION_LIFETIME)
@app.before_request
def make_session_permanent():
session.permanent = True
| Setting | Default | Production Recommendation |
|---|---|---|
| SESSION_COOKIE_SECURE | False | True (requires HTTPS) |
| SESSION_COOKIE_HTTPONLY | True | True |
| SESSION_COOKIE_SAMESITE | “Lax” | “Lax” or “Strict” |
| PERMANENT_SESSION_LIFETIME | 31 days | 1 hour to 1 day depending on risk |
For applications that store sensitive data in sessions or need to invalidate sessions server-side, use Flask-Session to store session data in Redis, a database, or the filesystem.
pip install flask-session redis
from flask import Flask, session
from flask_session import Session
import redis
app = Flask(__name__)
app.config.update(
SESSION_TYPE="redis",
SESSION_REDIS=redis.from_url("redis://localhost:6379"),
SESSION_PERMANENT=True,
PERMANENT_SESSION_LIFETIME=3600, # 1 hour
)
Session(app)
# Now session data is stored in Redis, not in the cookie
# The cookie only contains the session ID
Flask-Login is the most popular extension for managing user sessions in Flask. It handles the boilerplate of login, logout, session management, and route protection so you can focus on your application logic.
pip install flask-login
from flask import Flask from flask_login import LoginManager app = Flask(__name__) app.secret_key = "your-secret-key" # Initialize Flask-Login login_manager = LoginManager() login_manager.init_app(app) # Where to redirect unauthenticated users login_manager.login_view = "auth.login" # Flash message category for unauthorized access login_manager.login_message_category = "warning"
Flask-Login requires a User model that implements specific properties and methods. The UserMixin class provides sensible defaults for all of them.
from flask_login import UserMixin
class User(UserMixin):
"""User model for Flask-Login.
UserMixin provides:
- is_authenticated: True (user has valid credentials)
- is_active: True (account is not suspended)
- is_anonymous: False (this is a real user)
- get_id(): returns self.id as a string
"""
def __init__(self, id, username, email, password_hash, role="user"):
self.id = id
self.username = username
self.email = email
self.password_hash = password_hash
self.role = role
def check_password(self, password):
from werkzeug.security import check_password_hash
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f"<User {self.username}>"
# User loader callback: Flask-Login calls this on every request
# to load the user from the session cookie
@login_manager.user_loader
def load_user(user_id):
"""Load user by ID from your database.
This function is called on every request to deserialize the user
from the session. Return None if the user no longer exists.
"""
# With SQLAlchemy:
return User.query.get(int(user_id))
# With a dictionary (for demonstration):
# return users_db.get(int(user_id))
from flask import Blueprint, request, redirect, url_for, flash, render_template
from flask_login import login_user, logout_user, current_user, login_required
auth_bp = Blueprint("auth", __name__)
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
# If already logged in, redirect to dashboard
if current_user.is_authenticated:
return redirect(url_for("main.dashboard"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
remember = request.form.get("remember", False)
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
# login_user creates the session
login_user(user, remember=bool(remember))
# Redirect to the page they originally wanted
next_page = request.args.get("next")
return redirect(next_page or url_for("main.dashboard"))
flash("Invalid username or password.", "danger")
return render_template("login.html")
@auth_bp.route("/logout")
@login_required
def logout():
logout_user()
flash("You have been logged out.", "info")
return redirect(url_for("auth.login"))
@auth_bp.route("/profile")
@login_required
def profile():
# current_user is automatically available: it is the logged-in User object
return render_template("profile.html", user=current_user)
@login_required protects routes so that only authenticated users can access them. Unauthenticated users are redirected to the login_view you configured on the LoginManager.
from flask_login import login_required
@app.route("/settings")
@login_required
def settings():
"""Only authenticated users can access this page."""
return render_template("settings.html")
@app.route("/api/data")
@login_required
def api_data():
"""Protected API endpoint."""
return {"data": "sensitive information", "user": current_user.username}
When remember=True is passed to login_user(), Flask-Login sets a long-lived “remember me” cookie. Even if the session cookie expires (browser closes), the remember cookie will restore the session.
from datetime import timedelta # Configure remember me duration app.config["REMEMBER_COOKIE_DURATION"] = timedelta(days=14) app.config["REMEMBER_COOKIE_SECURE"] = True # HTTPS only app.config["REMEMBER_COOKIE_HTTPONLY"] = True # No JS access app.config["REMEMBER_COOKIE_SAMESITE"] = "Lax" # In login route: login_user(user, remember=True) # Sets the remember cookie
By default, Flask-Login redirects unauthenticated users to the login page. You can customize this behavior for API routes or special cases.
@login_manager.unauthorized_handler
def unauthorized():
"""Handle unauthorized access attempts."""
if request.is_json or request.path.startswith("/api/"):
# API requests get a JSON response
return {"error": "Authentication required"}, 401
# Browser requests get redirected to login
flash("Please log in to access this page.", "warning")
return redirect(url_for("auth.login", next=request.url))
Token-based authentication is the standard for REST APIs, single-page applications, and mobile apps. Instead of cookies and sessions, the client receives a signed token after login and sends it with every request in the Authorization header.
| Scenario | Use Sessions | Use Tokens (JWT) |
|---|---|---|
| Server-rendered web app (Jinja2 templates) | Yes | No |
| REST API consumed by frontend (React, Vue) | No | Yes |
| Mobile app backend | No | Yes |
| Microservices architecture | No | Yes |
| Third-party API access | No | Yes |
pip install flask-jwt-extended
from flask import Flask from flask_jwt_extended import JWTManager from datetime import timedelta app = Flask(__name__) # JWT Configuration app.config["JWT_SECRET_KEY"] = "your-jwt-secret-key" # Use env var in production app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30) app.config["JWT_TOKEN_LOCATION"] = ["headers"] # Can also use cookies, query_string app.config["JWT_HEADER_NAME"] = "Authorization" app.config["JWT_HEADER_TYPE"] = "Bearer" jwt = JWTManager(app)
Access tokens are short-lived and used for API access. Refresh tokens are long-lived and used only to obtain new access tokens without re-entering credentials.
from flask import request, jsonify
from flask_jwt_extended import (
create_access_token,
create_refresh_token,
jwt_required,
get_jwt_identity,
get_jwt,
)
from werkzeug.security import check_password_hash
@app.route("/api/auth/login", methods=["POST"])
def api_login():
"""Authenticate user and return JWT tokens."""
data = request.get_json()
if not data or not data.get("username") or not data.get("password"):
return jsonify({"error": "Username and password required"}), 400
user = User.query.filter_by(username=data["username"]).first()
if not user or not user.check_password(data["password"]):
return jsonify({"error": "Invalid credentials"}), 401
# Create tokens with user identity and additional claims
access_token = create_access_token(
identity=str(user.id),
additional_claims={
"username": user.username,
"role": user.role,
"email": user.email
}
)
refresh_token = create_refresh_token(identity=str(user.id))
return jsonify({
"access_token": access_token,
"refresh_token": refresh_token,
"user": {
"id": user.id,
"username": user.username,
"role": user.role
}
}), 200
@app.route("/api/profile", methods=["GET"])
@jwt_required()
def api_profile():
"""Protected endpoint: requires valid access token."""
current_user_id = get_jwt_identity() # Returns the identity from the token
claims = get_jwt() # Returns all claims in the token
user = User.query.get(int(current_user_id))
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify({
"id": user.id,
"username": user.username,
"email": user.email,
"role": claims.get("role")
})
@app.route("/api/admin/users", methods=["GET"])
@jwt_required()
def admin_list_users():
"""Admin-only endpoint."""
claims = get_jwt()
if claims.get("role") != "admin":
return jsonify({"error": "Admin access required"}), 403
users = User.query.all()
return jsonify([
{"id": u.id, "username": u.username, "role": u.role}
for u in users
])
The client sends the token like this:
# Login and get tokens
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "alice123"}'
# Use access token to call protected endpoint
curl http://localhost:5000/api/profile \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
# Refresh the access token
curl -X POST http://localhost:5000/api/auth/refresh \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
@app.route("/api/auth/refresh", methods=["POST"])
@jwt_required(refresh=True)
def refresh():
"""Use refresh token to get a new access token."""
current_user_id = get_jwt_identity()
user = User.query.get(int(current_user_id))
if not user:
return jsonify({"error": "User not found"}), 404
new_access_token = create_access_token(
identity=str(user.id),
additional_claims={
"username": user.username,
"role": user.role,
"email": user.email
}
)
return jsonify({"access_token": new_access_token}), 200
The typical flow is:
JWTs are stateless: once issued, they are valid until they expire. To revoke tokens (for logout, password change, or security incidents), you need a blocklist.
from flask_jwt_extended import get_jwt
# In-memory blocklist (use Redis in production)
BLOCKLIST = set()
@jwt.token_in_blocklist_loader
def check_if_token_revoked(jwt_header, jwt_payload):
"""Check if a token has been revoked.
This callback is called on every request to a protected endpoint.
Return True if the token is revoked (blocked).
"""
jti = jwt_payload["jti"] # JWT ID: unique identifier for the token
return jti in BLOCKLIST
@app.route("/api/auth/logout", methods=["POST"])
@jwt_required()
def api_logout():
"""Revoke the current access token."""
jti = get_jwt()["jti"]
BLOCKLIST.add(jti)
return jsonify({"message": "Token revoked successfully"}), 200
@app.route("/api/auth/logout-all", methods=["POST"])
@jwt_required()
def api_logout_all():
"""Revoke both access and refresh tokens."""
jti = get_jwt()["jti"]
BLOCKLIST.add(jti)
# In practice, you would also revoke the refresh token
# by storing revoked tokens in Redis with TTL matching token expiry
return jsonify({"message": "All tokens revoked"}), 200
Production blocklist with Redis:
import redis
redis_client = redis.from_url("redis://localhost:6379")
@jwt.token_in_blocklist_loader
def check_if_token_revoked(jwt_header, jwt_payload):
jti = jwt_payload["jti"]
token_in_redis = redis_client.get(f"blocklist:{jti}")
return token_in_redis is not None
def revoke_token(jti, expires_in):
"""Add token to blocklist with TTL matching token expiry."""
redis_client.setex(f"blocklist:{jti}", expires_in, "revoked")
RBAC restricts access based on user roles. Instead of checking individual permissions, you assign roles (admin, editor, user) and define what each role can do.
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
# Many-to-many relationship between users and roles
user_roles = db.Table("user_roles",
db.Column("user_id", db.Integer, db.ForeignKey("users.id"), primary_key=True),
db.Column("role_id", db.Integer, db.ForeignKey("roles.id"), primary_key=True)
)
class Role(db.Model):
__tablename__ = "roles"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.String(200))
def __repr__(self):
return f"<Role {self.name}>"
class User(UserMixin, db.Model):
__tablename__ = "users"
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)
is_active_user = db.Column(db.Boolean, default=True)
# Many-to-many relationship
roles = db.relationship("Role", secondary=user_roles,
backref=db.backref("users", lazy="dynamic"))
def has_role(self, role_name):
"""Check if user has a specific role."""
return any(role.name == role_name for role in self.roles)
def has_any_role(self, *role_names):
"""Check if user has any of the specified roles."""
return any(self.has_role(name) for name in role_names)
@property
def is_admin(self):
return self.has_role("admin")
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)
from functools import wraps
from flask import abort
from flask_login import current_user, login_required
def role_required(*roles):
"""Decorator that requires the user to have one of the specified roles.
Usage:
@role_required("admin")
@role_required("admin", "editor")
"""
def decorator(f):
@wraps(f)
@login_required # Ensure user is authenticated first
def decorated_function(*args, **kwargs):
if not current_user.has_any_role(*roles):
abort(403) # Forbidden
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_required(f):
"""Shortcut decorator for admin-only routes."""
@wraps(f)
@login_required
def decorated_function(*args, **kwargs):
if not current_user.is_admin:
abort(403)
return f(*args, **kwargs)
return decorated_function
# Usage
@app.route("/admin/dashboard")
@admin_required
def admin_dashboard():
return render_template("admin/dashboard.html")
@app.route("/editor/posts")
@role_required("admin", "editor")
def manage_posts():
return render_template("editor/posts.html")
@app.route("/api/admin/users", methods=["DELETE"])
@role_required("admin")
def delete_user():
# Only admins can delete users
user_id = request.json.get("user_id")
user = User.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
return jsonify({"message": "User deleted"}), 200
from flask_jwt_extended import jwt_required, get_jwt
def jwt_role_required(*roles):
"""Decorator for JWT-protected endpoints with role checking."""
def decorator(f):
@wraps(f)
@jwt_required()
def decorated_function(*args, **kwargs):
claims = get_jwt()
user_role = claims.get("role", "")
if user_role not in roles:
return jsonify({"error": "Insufficient permissions"}), 403
return f(*args, **kwargs)
return decorated_function
return decorator
@app.route("/api/admin/settings", methods=["PUT"])
@jwt_role_required("admin")
def update_settings():
"""Only admins can update application settings."""
data = request.get_json()
# ... update settings ...
return jsonify({"message": "Settings updated"}), 200
In Jinja2 templates, you can show or hide elements based on the user’s role.
<!-- Navigation showing role-specific links -->
<nav>
<a href="{{ url_for('main.home') }}">Home</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.dashboard') }}">Admin Panel</a>
<a href="{{ url_for('admin.users') }}">Manage Users</a>
{% endif %}
{% if current_user.has_any_role('admin', 'editor') %}
<a href="{{ url_for('editor.posts') }}">Manage Posts</a>
{% endif %}
<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 %}
</nav>
<!-- Conditionally show delete button -->
{% if current_user.is_admin %}
<button class="btn btn-danger" onclick="deleteUser({{ user.id }})">
Delete User
</button>
{% endif %}
OAuth2 lets users log in with their existing accounts from providers like Google, GitHub, or Facebook. Instead of managing passwords yourself, you delegate authentication to a trusted provider. Flask-Dance makes this straightforward.
pip install flask-dance[sqla]
from flask import Flask, redirect, url_for
from flask_dance.contrib.google import make_google_blueprint, google
from flask_dance.contrib.github import make_github_blueprint, github
from flask_login import login_user, current_user
import os
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]
# Google OAuth blueprint
google_bp = make_google_blueprint(
client_id=os.environ["GOOGLE_CLIENT_ID"],
client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
scope=["openid", "email", "profile"],
redirect_url="/auth/google/callback"
)
app.register_blueprint(google_bp, url_prefix="/auth/google")
# GitHub OAuth blueprint
github_bp = make_github_blueprint(
client_id=os.environ["GITHUB_CLIENT_ID"],
client_secret=os.environ["GITHUB_CLIENT_SECRET"],
scope="user:email",
)
app.register_blueprint(github_bp, url_prefix="/auth/github")
@app.route("/auth/google/callback")
def google_callback():
"""Handle Google OAuth callback."""
if not google.authorized:
return redirect(url_for("google.login"))
# Get user info from Google
resp = google.get("/oauth2/v2/userinfo")
if resp.ok:
google_info = resp.json()
email = google_info["email"]
name = google_info.get("name", "")
# Find or create user
user = User.query.filter_by(email=email).first()
if not user:
user = User(
username=email.split("@")[0],
email=email,
password_hash="", # No password for OAuth users
oauth_provider="google"
)
db.session.add(user)
db.session.commit()
login_user(user)
return redirect(url_for("main.dashboard"))
return "Failed to get user info from Google", 400
@app.route("/auth/github/callback")
def github_callback():
"""Handle GitHub OAuth callback."""
if not github.authorized:
return redirect(url_for("github.login"))
resp = github.get("/user")
if resp.ok:
github_info = resp.json()
github_id = str(github_info["id"])
username = github_info["login"]
email = github_info.get("email", f"{username}@github.user")
user = User.query.filter_by(email=email).first()
if not user:
user = User(
username=username,
email=email,
password_hash="",
oauth_provider="github"
)
db.session.add(user)
db.session.commit()
login_user(user)
return redirect(url_for("main.dashboard"))
return "Failed to get user info from GitHub", 400
The login template with social login buttons:
<div class="social-login">
<a href="{{ url_for('google.login') }}" class="btn btn-danger btn-block">
Login with Google
</a>
<a href="{{ url_for('github.login') }}" class="btn btn-dark btn-block">
Login with GitHub
</a>
<hr>
<p>Or login with your credentials:</p>
<form method="post">
<!-- regular login form -->
</form>
</div>
Let us build a complete authentication system that combines everything: user registration with validation, login/logout with Flask-Login, a protected dashboard, an admin panel with role checks, and a password reset flow.
flask_auth_app/ +-- app/ | +-- __init__.py # Application factory | +-- models.py # User and Role models | +-- auth/ | | +-- __init__.py # Auth blueprint | | +-- routes.py # Login, register, logout, reset | | +-- forms.py # WTForms for validation | | +-- utils.py # Email sending, token generation | +-- main/ | | +-- __init__.py # Main blueprint | | +-- routes.py # Dashboard, home | +-- admin/ | | +-- __init__.py # Admin blueprint | | +-- routes.py # Admin panel | +-- templates/ | +-- base.html | +-- auth/ | | +-- login.html | | +-- register.html | | +-- reset_password.html | +-- main/ | | +-- dashboard.html | +-- admin/ | +-- panel.html +-- config.py +-- requirements.txt +-- run.py
import os
from datetime import timedelta
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key")
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL", "sqlite:///app.db"
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Session
PERMANENT_SESSION_LIFETIME = timedelta(hours=2)
SESSION_COOKIE_SECURE = os.environ.get("FLASK_ENV") == "production"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
# Remember me
REMEMBER_COOKIE_DURATION = timedelta(days=14)
REMEMBER_COOKIE_SECURE = os.environ.get("FLASK_ENV") == "production"
# Password reset tokens
RESET_TOKEN_EXPIRY = 3600 # 1 hour in seconds
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
from itsdangerous import URLSafeTimedSerializer
from flask import current_app
from app import db
user_roles = db.Table("user_roles",
db.Column("user_id", db.Integer, db.ForeignKey("users.id"), primary_key=True),
db.Column("role_id", db.Integer, db.ForeignKey("roles.id"), primary_key=True)
)
class Role(db.Model):
__tablename__ = "roles"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
@staticmethod
def insert_roles():
"""Seed default roles."""
roles = ["user", "editor", "admin"]
for role_name in roles:
if not Role.query.filter_by(name=role_name).first():
db.session.add(Role(name=role_name))
db.session.commit()
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(256), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime)
is_active_account = db.Column(db.Boolean, default=True)
roles = db.relationship("Role", secondary=user_roles,
backref=db.backref("users", lazy="dynamic"))
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)
def has_role(self, role_name):
return any(r.name == role_name for r in self.roles)
@property
def is_admin(self):
return self.has_role("admin")
def get_reset_token(self):
"""Generate a password reset token."""
s = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
return s.dumps({"user_id": self.id}, salt="password-reset")
@staticmethod
def verify_reset_token(token, max_age=3600):
"""Verify a password reset token."""
s = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
try:
data = s.loads(token, salt="password-reset", max_age=max_age)
return User.query.get(data["user_id"])
except Exception:
return None
def __repr__(self):
return f"<User {self.username}>"
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
from config import Config
db = SQLAlchemy()
login_manager = LoginManager()
csrf = CSRFProtect()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize extensions
db.init_app(app)
login_manager.init_app(app)
csrf.init_app(app)
# Flask-Login configuration
login_manager.login_view = "auth.login"
login_manager.login_message_category = "warning"
# User loader
from app.models import User
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# Register blueprints
from app.auth import auth_bp
from app.main import main_bp
from app.admin import admin_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
app.register_blueprint(admin_bp, url_prefix="/admin")
# Create tables
with app.app_context():
db.create_all()
from app.models import Role
Role.insert_roles()
return app
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import (
DataRequired, Email, EqualTo, Length, ValidationError, Regexp
)
from app.models import User
class RegistrationForm(FlaskForm):
username = StringField("Username", validators=[
DataRequired(),
Length(min=3, max=80),
Regexp(
r"^[a-zA-Z0-9_]+$",
message="Username can only contain letters, numbers, and underscores."
)
])
email = StringField("Email", validators=[
DataRequired(),
Email(),
Length(max=120)
])
password = PasswordField("Password", validators=[
DataRequired(),
Length(min=8, message="Password must be at least 8 characters.")
])
confirm_password = PasswordField("Confirm Password", validators=[
DataRequired(),
EqualTo("password", message="Passwords must match.")
])
submit = SubmitField("Register")
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError("Username is already taken.")
def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError("Email is already registered.")
class LoginForm(FlaskForm):
username = StringField("Username", validators=[DataRequired()])
password = PasswordField("Password", validators=[DataRequired()])
remember = BooleanField("Remember Me")
submit = SubmitField("Login")
class ResetRequestForm(FlaskForm):
email = StringField("Email", validators=[DataRequired(), Email()])
submit = SubmitField("Request Password Reset")
class ResetPasswordForm(FlaskForm):
password = PasswordField("New Password", validators=[
DataRequired(),
Length(min=8)
])
confirm_password = PasswordField("Confirm Password", validators=[
DataRequired(),
EqualTo("password")
])
submit = SubmitField("Reset Password")
from datetime import datetime
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, current_user, login_required
from app import db
from app.auth import auth_bp
from app.auth.forms import (
RegistrationForm, LoginForm, ResetRequestForm, ResetPasswordForm
)
from app.models import User, Role
@auth_bp.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("main.dashboard"))
form = RegistrationForm()
if form.validate_on_submit():
user = User(
username=form.username.data.strip(),
email=form.email.data.strip().lower()
)
user.set_password(form.password.data)
# Assign default role
default_role = Role.query.filter_by(name="user").first()
if default_role:
user.roles.append(default_role)
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("main.dashboard"))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data.strip()).first()
if user and user.check_password(form.password.data):
if not user.is_active_account:
flash("Your account has been deactivated.", "danger")
return render_template("auth/login.html", form=form)
login_user(user, remember=form.remember.data)
user.last_login = datetime.utcnow()
db.session.commit()
next_page = request.args.get("next")
return redirect(next_page or url_for("main.dashboard"))
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"))
@auth_bp.route("/reset-password", methods=["GET", "POST"])
def reset_request():
if current_user.is_authenticated:
return redirect(url_for("main.dashboard"))
form = ResetRequestForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.strip().lower()).first()
if user:
token = user.get_reset_token()
# In production, send this via email
# send_reset_email(user, token)
flash(
"If that email exists, a reset link has been sent.",
"info"
)
else:
# Do not reveal whether email exists
flash("If that email exists, a reset link has been sent.", "info")
return redirect(url_for("auth.login"))
return render_template("auth/reset_request.html", form=form)
@auth_bp.route("/reset-password/<token>", methods=["GET", "POST"])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for("main.dashboard"))
user = User.verify_reset_token(token)
if not user:
flash("Invalid or expired reset token.", "danger")
return redirect(url_for("auth.reset_request"))
form = ResetPasswordForm()
if form.validate_on_submit():
user.set_password(form.password.data)
db.session.commit()
flash("Your password has been reset. Please log in.", "success")
return redirect(url_for("auth.login"))
return render_template("auth/reset_password.html", form=form)
from flask import render_template
from flask_login import login_required, current_user
from app.main import main_bp
@main_bp.route("/")
def home():
return render_template("main/home.html")
@main_bp.route("/dashboard")
@login_required
def dashboard():
return render_template("main/dashboard.html", user=current_user)
from flask import render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user
from functools import wraps
from app import db
from app.admin import admin_bp
from app.models import User, Role
def admin_required(f):
@wraps(f)
@login_required
def decorated(*args, **kwargs):
if not current_user.is_admin:
abort(403)
return f(*args, **kwargs)
return decorated
@admin_bp.route("/")
@admin_required
def panel():
users = User.query.order_by(User.created_at.desc()).all()
return render_template("admin/panel.html", users=users)
@admin_bp.route("/user/<int:user_id>/toggle-active", methods=["POST"])
@admin_required
def toggle_user_active(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash("You cannot deactivate your own account.", "warning")
return redirect(url_for("admin.panel"))
user.is_active_account = not user.is_active_account
db.session.commit()
status = "activated" if user.is_active_account else "deactivated"
flash(f"User {user.username} has been {status}.", "success")
return redirect(url_for("admin.panel"))
@admin_bp.route("/user/<int:user_id>/change-role", methods=["POST"])
@admin_required
def change_user_role(user_id):
user = User.query.get_or_404(user_id)
new_role_name = request.form.get("role")
role = Role.query.filter_by(name=new_role_name).first()
if not role:
flash("Invalid role.", "danger")
return redirect(url_for("admin.panel"))
user.roles = [role]
db.session.commit()
flash(f"User {user.username} role changed to {new_role_name}.", "success")
return redirect(url_for("admin.panel"))
The password reset flow uses signed tokens (via itsdangerous) to verify that the reset request is legitimate.
# How the password reset flow works:
# 1. User requests a password reset by providing their email
# 2. Server generates a signed, time-limited token containing the user ID
# 3. Token is sent to user's email as a link (e.g., /reset-password/TOKEN)
# 4. User clicks the link, server verifies the token
# 5. If valid and not expired, user sets a new password
# 6. Old sessions are invalidated
# Token generation (in the User model):
from itsdangerous import URLSafeTimedSerializer
def get_reset_token(self):
s = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
return s.dumps({"user_id": self.id}, salt="password-reset")
# Token verification:
@staticmethod
def verify_reset_token(token, max_age=3600):
s = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
try:
data = s.loads(token, salt="password-reset", max_age=max_age)
return User.query.get(data["user_id"])
except Exception:
return None
In production, you would send the reset email using Flask-Mail:
from flask_mail import Mail, Message
mail = Mail(app)
def send_reset_email(user):
token = user.get_reset_token()
msg = Message(
subject="Password Reset Request",
sender="noreply@yourapp.com",
recipients=[user.email]
)
msg.body = f"""To reset your password, visit the following link:
{url_for('auth.reset_password', token=token, _external=True)}
If you did not request this, ignore this email.
This link expires in 1 hour.
"""
mail.send(msg)
Cross-Site Request Forgery (CSRF) attacks trick authenticated users into making unintended requests. Flask-WTF provides built-in CSRF protection.
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app)
# In templates, include the CSRF token in forms:
# {{ form.hidden_tag() }} for Flask-WTF forms
# OR manually:
# <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
# For AJAX requests, include the token in headers:
# X-CSRFToken: {{ csrf_token() }}
from flask_talisman import Talisman
# Force HTTPS and set security headers
talisman = Talisman(
app,
force_https=True,
strict_transport_security=True,
session_cookie_secure=True,
content_security_policy={
"default-src": "'self'",
"script-src": "'self'",
"style-src": "'self' 'unsafe-inline'",
}
)
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(
app=app,
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"]
)
@app.route("/login", methods=["POST"])
@limiter.limit("5 per minute") # Max 5 login attempts per minute
def login():
# ... login logic ...
pass
@app.route("/api/auth/login", methods=["POST"])
@limiter.limit("10 per minute")
def api_login():
# ... API login logic ...
pass
# Production cookie configuration checklist
app.config.update(
SESSION_COOKIE_SECURE=True, # HTTPS only
SESSION_COOKIE_HTTPONLY=True, # No JavaScript access
SESSION_COOKIE_SAMESITE="Lax", # CSRF protection
REMEMBER_COOKIE_SECURE=True, # HTTPS only for remember me
REMEMBER_COOKIE_HTTPONLY=True, # No JS access to remember cookie
)
import bleach
def sanitize_input(value):
"""Remove HTML tags and dangerous content from user input."""
if value is None:
return ""
return bleach.clean(value.strip())
# Usage in routes:
username = sanitize_input(request.form.get("username"))
email = sanitize_input(request.form.get("email"))
# SQLAlchemy parameterized queries prevent SQL injection automatically:
# SAFE:
user = User.query.filter_by(username=username).first()
# NEVER do this:
# db.session.execute(f"SELECT * FROM users WHERE username = '{username}'")
| Security Measure | Tool/Library | Why |
|---|---|---|
| Password hashing | werkzeug.security / bcrypt | Never store plaintext passwords |
| CSRF protection | Flask-WTF (CSRFProtect) | Prevent cross-site form submissions |
| HTTPS | Flask-Talisman | Encrypt data in transit |
| Secure cookies | Flask config | Prevent cookie theft |
| Rate limiting | Flask-Limiter | Prevent brute-force attacks |
| Input sanitization | bleach / WTForms validators | Prevent XSS and injection |
| SQL injection prevention | SQLAlchemy ORM | Parameterized queries |
| Token expiration | JWT / itsdangerous | Limit exposure window |
| Security headers | Flask-Talisman | Browser security policies |
# WRONG: Never do this user.password = request.form["password"] # CORRECT: Always hash passwords user.password_hash = generate_password_hash(request.form["password"])
Session fixation attacks occur when the session ID is not regenerated after login. An attacker sets a known session ID, waits for the victim to log in, and then hijacks the session.
# WRONG: Reusing the same session after login
session["user_id"] = user.id
# CORRECT: Regenerate the session on login
from flask import session
@app.route("/login", methods=["POST"])
def login():
# ... validate credentials ...
session.clear() # Clear old session data
session["user_id"] = user.id # Start fresh session
# Flask-Login's login_user() handles this correctly
# WRONG: Form without CSRF token
# <form method="post">
# <input name="amount">
# <button>Transfer</button>
# </form>
# CORRECT: Include CSRF token
# <form method="post">
# {{ form.hidden_tag() }}
# <input name="amount">
# <button>Transfer</button>
# </form>
# WRONG: Decoding without verification
import jwt
data = jwt.decode(token, options={"verify_signature": False})
# CORRECT: Always verify the signature
data = jwt.decode(token, app.config["SECRET_KEY"], algorithms=["HS256"])
# WRONG: Revealing whether a username/email exists
if not user:
flash("No account with that username.") # Tells attacker the username does not exist
elif not user.check_password(password):
flash("Wrong password.") # Tells attacker the username exists
# CORRECT: Generic error message
if not user or not user.check_password(password):
flash("Invalid credentials.") # Attacker learns nothing
# WRONG: Tokens that never expire and cannot be revoked
access_token = create_access_token(identity=user.id, expires_delta=False)
# CORRECT: Short-lived tokens with refresh mechanism and blocklist
access_token = create_access_token(
identity=str(user.id),
expires_delta=timedelta(hours=1)
)
refresh_token = create_refresh_token(
identity=str(user.id),
expires_delta=timedelta(days=30)
)
| # | Concept | Key Point |
|---|---|---|
| 1 | Authentication vs Authorization | Authentication verifies identity; authorization checks permissions. Always authenticate first. |
| 2 | Password Hashing | Never store plaintext passwords. Use generate_password_hash (Werkzeug) or bcrypt. |
| 3 | Sessions | Flask sessions are signed but not encrypted. Use server-side sessions (Redis) for sensitive data. |
| 4 | Flask-Login | The standard for session-based auth in Flask. Use UserMixin, login_user(), @login_required. |
| 5 | JWT Tokens | Use for APIs, SPAs, and mobile apps. Short-lived access tokens + long-lived refresh tokens. |
| 6 | Token Revocation | JWTs are stateless: use a Redis blocklist to revoke tokens on logout or security events. |
| 7 | RBAC | Use many-to-many user-role relationships with custom decorators like @role_required. |
| 8 | OAuth2 | Flask-Dance simplifies social login (Google, GitHub). Delegate authentication to trusted providers. |
| 9 | CSRF Protection | Always use Flask-WTF’s CSRF protection on forms. Include tokens in AJAX requests. |
| 10 | Defense in Depth | Combine HTTPS, secure cookies, rate limiting, input validation, and parameterized queries. |
Authentication and authorization are foundational to any web application. Start with Flask-Login for session-based auth in server-rendered apps, and Flask-JWT-Extended for APIs. Layer on RBAC when your application needs fine-grained permissions. Always hash passwords, always use CSRF protection, and always serve over HTTPS in production. These are not optional: they are the minimum baseline for any application that handles user data.