security intermediate

Role-Based Access Control with Decorators

min read Frederick Tubiermont

Role-Based Access Control with Decorators

Once you have authentication, you need authorization: controlling what different users can access. In Flask, decorators are the clean way to do this.

Two Roles, Two Decorators

The pattern: store role in the session at login, check it in each decorator.

-- Users table with role column
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    role VARCHAR(50) DEFAULT 'user',  -- 'user' or 'admin'
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Understanding functools.wraps

Before building decorators, understand @wraps. Without it, all your protected routes would have the same name (decorated), breaking Flask's URL routing.

from functools import wraps

# WITHOUT @wraps — broken
def login_required(f):
    def decorated(*args, **kwargs):
        pass
    return decorated  # Flask sees function named "decorated"

# WITH @wraps — correct
def login_required(f):
    @wraps(f)  # Copies name, docstring, and other metadata from f
    def decorated(*args, **kwargs):
        pass
    return decorated  # Flask sees original function name

The login_required Decorator

from functools import wraps
from flask import session, redirect, request

def login_required(f):
    """Redirect to login if user is not authenticated."""
    @wraps(f)
    def decorated(*args, **kwargs):
        if "user_id" not in session:
            # Save the page they tried to access
            return redirect(f"/login?next={request.path}")
        return f(*args, **kwargs)
    return decorated

The admin_required Decorator

def admin_required(f):
    """Return 403 if user is not an admin. Must be logged in first."""
    @wraps(f)
    def decorated(*args, **kwargs):
        if "user_id" not in session:
            return redirect("/login")
        if session.get("role") != "admin":
            return "Access denied", 403
        return f(*args, **kwargs)
    return decorated

Stacking Decorators

Order matters. At request time, decorators execute from outermost to innermost — top to bottom. Put @login_required closest to the function so it runs first (checking auth before role):

@app.route("/admin/users")
@admin_required          # Applied second (outer)
@login_required          # Applied first (inner, closest to function)
def admin_users():
    # Only admins reach here
    with get_db() as conn:
        with conn.cursor() as cur:
            cur.execute("SELECT id, email, role, created_at FROM users ORDER BY created_at DESC")
            users = cur.fetchall()
    return render_template("admin/users.html", users=users)

A Practical Example: User vs Admin Dashboard

@app.route("/dashboard")
@login_required
def dashboard():
    """Any logged-in user can access this."""
    return render_template("dashboard.html", role=session["role"])


@app.route("/admin")
@admin_required
def admin_panel():
    """Only admins can access this."""
    with get_db() as conn:
        with conn.cursor() as cur:
            cur.execute("SELECT COUNT(*) as count FROM users")
            stats = cur.fetchone()
    return render_template("admin/panel.html", stats=stats)


@app.route("/admin/make-admin/<int:user_id>", methods=["POST"])
@admin_required
def make_admin(user_id):
    """Promote a user to admin."""
    with get_db() as conn:
        with conn.cursor() as cur:
            cur.execute(
                "UPDATE users SET role = 'admin' WHERE id = %s",
                (user_id,)
            )
            conn.commit()
    return redirect("/admin/users")

Redirecting Back After Login

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        # ... authentication logic ...
        session["user_id"] = user["id"]
        session["role"] = user["role"]

        # Redirect to the page they originally requested
        # Validate next_url to prevent open redirect attacks
        next_url = request.args.get("next", "/dashboard")
        if not next_url.startswith("/") or next_url.startswith("//"):
            next_url = "/dashboard"
        return redirect(next_url)

    return render_template("login.html")

Showing Different UI Based on Role

In your Jinja2 templates, use session directly:

{% if session.get("role") == "admin" %}
<a href="/admin">Admin Panel</a>
{% endif %}

{% if session.get("user_id") %}
<a href="/logout">Logout</a>
{% else %}
<a href="/login">Login</a>
{% endif %}

Putting the Decorators in Their Own File

Keep decorators in utils/auth.py so they're reusable across blueprints:

# utils/auth.py
from functools import wraps
from flask import session, redirect, request

def login_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if "user_id" not in session:
            return redirect(f"/login?next={request.path}")
        return f(*args, **kwargs)
    return decorated

def admin_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if "user_id" not in session:
            return redirect("/login")
        if session.get("role") != "admin":
            return "Access denied", 403
        return f(*args, **kwargs)
    return decorated
# In any route file
from utils.auth import login_required, admin_required

@app.route("/settings")
@login_required
def settings():
    pass

Security Notes

  • Never trust role from form data — only from the session (server-side)
  • Refresh session role when you change a user's role in the DB
  • For sensitive admin actions, consider re-verifying the password even for logged-in admins

Was this helpful?

Get More Flask Vibe Tutorials

Join 1,000+ developers getting weekly Flask tips and AI-friendly code patterns.

No spam. Unsubscribe anytime.