Flask – Authentication & Authorization

Introduction

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.

Authentication vs Authorization

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.

Common Approaches

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

1. Password Hashing

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.

Werkzeug’s Built-in Hashing

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:

  • The hash includes the algorithm, iteration count, salt, and hash value all in one string
  • Each call to generate_password_hash produces a different result because of random salt
  • check_password_hash extracts the salt from the stored hash and recomputes
  • Default iteration count (600,000 for sha256) is deliberately slow to resist brute-force attacks

Bcrypt Alternative

Bcrypt 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.


2. Session-Based Authentication

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.

How Flask Sessions Work

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.

Building Login/Logout from Scratch

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)

Session Configuration

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

Server-Side Sessions

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

3. Flask-Login

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.

Installation and Setup

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"

UserMixin and User Loader

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))

Login, Logout, and current_user

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)

The @login_required Decorator

@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}

“Remember Me” Functionality

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

Custom Unauthorized Handler

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))

4. Token-Based Authentication (JWT)

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.

When to Use Tokens vs Sessions

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

Flask-JWT-Extended Setup

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)

Creating Tokens (Access + Refresh)

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

Protecting Routes with @jwt_required

@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..."

Token Refresh Flow

@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:

  1. Client logs in, receives access token (1 hour) and refresh token (30 days)
  2. Client uses access token for API calls
  3. When access token expires, client uses refresh token to get a new access token
  4. When refresh token expires, user must log in again

Token Revocation / Blacklisting

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")

5. Role-Based Access Control (RBAC)

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.

User Roles Model

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)

Custom Decorators

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

JWT-Based Role Checking

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

Template-Level Access Control

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 %}

6. OAuth2 / Social Login

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.

Flask-Dance Setup (Google Login)

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>

7. Practical Example: Complete Auth System

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.

Project Structure

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

Configuration (config.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

User Model (app/models.py)

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}>"

Application Factory (app/__init__.py)

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

Auth Forms (app/auth/forms.py)

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")

Auth Routes (app/auth/routes.py)

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)

Protected Dashboard (app/main/routes.py)

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)

Admin Panel with Role Check (app/admin/routes.py)

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"))

Password Reset Flow

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)

8. Security Best Practices

CSRF Protection

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() }}

HTTPS Enforcement

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'",
    }
)

Rate Limiting Login Attempts

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

Secure Cookie Configuration

# 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
)

Input Sanitization

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}'")

Complete Security Checklist

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

9. Common Pitfalls

1. Storing Plaintext Passwords

# WRONG: Never do this
user.password = request.form["password"]

# CORRECT: Always hash passwords
user.password_hash = generate_password_hash(request.form["password"])

2. Session Fixation

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

3. Missing CSRF Protection on Forms

# 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>

4. Not Validating JWT Tokens Properly

# 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"])

5. Leaking User Information

# 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

6. Long-Lived Tokens Without Revocation

# 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)
)

10. Key Takeaways

# 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.




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *