Role-Based Access Control with Decorators
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
rolefrom 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.