PyFu

Broken Access Control in Flask Applications

Python-based Web Application Attacks

This example demonstrates a common access control vulnerability in Flask applications, where some endpoints enforce proper authentication and role-based access, but one sensitive endpoint lacks the required checks.

from flask import Flask, request, session, jsonify, redirect, url_for
from functools import wraps

app = Flask(__name__)
app.secret_key = 'super-secret-key'

# Simulated users database
USERS = {
    'admin': {'password': 'admin123', 'role': 'admin'},
    'user1': {'password': 'user123', 'role': 'user'}
}

# Authentication and authorization decorator
def admin_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'username' not in session:
            return jsonify({'error': 'Authentication required'}), 401
        if session.get('role') != 'admin':
            return jsonify({'error': 'Admin role required'}), 403
        return f(*args, **kwargs)
    return decorated_function

# Login endpoint
@app.route('/login', methods=['POST'])
def login():
    data = request.json
    username = data.get('username')
    password = data.get('password')

    user = USERS.get(username)
    if user and user['password'] == password:
        session['username'] = username
        session['role'] = user['role']
        return jsonify({'message': f'Welcome {username}!'})
    return jsonify({'error': 'Invalid credentials'}), 401

# Protected admin dashboard
@app.route('/admin/dashboard')
@admin_required
def admin_dashboard():
    return jsonify({'message': 'Welcome to admin dashboard.'})

# Another protected endpoint
@app.route('/admin/config')
@admin_required
def admin_config():
    return jsonify({'config': 'Sensitive system configurations here'})

# Vulnerable unprotected endpoint
@app.route('/admin/secret-data')
def secret_data():
    sensitive_data = {
        'api_key': 'API-SECRET-123456',
        'db_password': 'super-secret-db-pass',
        'internal_token': 'internal-token-abcdef'
    }
    return jsonify({'sensitive_data': sensitive_data})

if __name__ == '__main__':
    app.run(debug=True)

The application allows users to login, creates a session, and uses a decorator to protect almost every route.

In previous section, we discussed Flask Decorators and how they works, in this example, we will see how a missing authorization decoratror could lead to a broken access control and exposing sensitive data.

First of all, we see that when a user successfully logs in, a session is created containing the user’s role information; This session data is later used by the decorator to validate the user’s privileges on each protected request.

# Login endpoint
@app.route('/login', methods=['POST'])
def login():
    data = request.json
    username = data.get('username')
    password = data.get('password')

    user = USERS.get(username)
    if user and user['password'] == password:
        session['username'] = username
        session['role'] = user['role']
        return jsonify({'message': f'Welcome {username}!'})
    return jsonify({'error': 'Invalid credentials'}), 401

We notice that the application has multiple routes exposed, and it uses the admin_required decorator to enforce security checks on sensitive endpoints.

The decorator is responsible for verifying if the user is authenticated and if their role matches the required admin privileges before granting access.

This design helps protect most of the administrative functionality. However, if any endpoint is implemented without applying the decorator, like the /admin/secret-data route, it may unintentionally expose sensitive information to unauthenticated or unauthorized users.

If we take a closer look at the /admin/secret-data route, we can easily spot that the decorator admin_required isn’t called, which means any user can access that route and get a valid response.

# Vulnerable unprotected endpoint
@app.route('/admin/secret-data')
# No decorator?
def secret_data():
    sensitive_data = {
        'api_key': 'API-SECRET-123456',
        'db_password': 'super-secret-db-pass',
        'internal_token': 'internal-token-abcdef'
    }
    return jsonify({'sensitive_data': sensitive_data})

First, let’s try to send unauthenticated request to /admin/config route which calls the admin_required decorator and see if we will be able to access it:

hackpad :: ~ » curl -v http://localhost:5000/admin/config

< HTTP/1.1 401 UNAUTHORIZED

< 
{
  "error": "Authentication required"
}

As expected, we can see that the server responded with 401 UNAUTHORIZED because there is no valid session established yet.

This indicates that the admin_required decorator correctly applied its logic, preventing unauthenticated users from accessing the protected endpoint.

Now let’s try to access the /admin/secret-data endpoint and see what we will get:

» curl -v http://localhost:5000/admin/secret-data 

< HTTP/1.1 200 OK

{
  "sensitive_data": {
    "api_key": "API-SECRET-123456",
    "db_password": "super-secret-db-pass",
    "internal_token": "internal-token-abcdef"
  }
}

We can see that we received a valid response and successfully accessed the sensitive data exposed by that endpoint.

This confirms that the endpoint lacks proper authentication and authorization checks, allowing any user to retrieve sensitive information without restrictions.

You can try by yourself to login and obtain a valid session, see what changes in requests and why is that happening.

There are many ways an access control issue can occur in Flask applications, and this example demonstrates one of them.

When developers forget to apply the proper authentication and authorization decorators on sensitive routes, these endpoints become exposed to unauthorized access, even if the rest of the application enforces strict access control on other routes.

Why broken access control matters from an offensive security perspective

I chase missing-decorator bugs because they are pure profit: no payload, no chain, no auth. A single route that forgot @admin_required hands me whatever it returns, and in real Flask apps that is config dumps, API keys, internal tokens, or admin actions reachable by an anonymous request. It is the most common serious finding I report on Flask targets precisely because the protection is opt-in per route, so the bug is a developer forgetting one line rather than a flawed algorithm. That makes coverage, not strength, the weak point, and coverage gaps scale with the size of the route table.

When I assess a Flask app, these are the tells I hunt for:

  • Decorator applied inconsistently across sibling routes. When /admin/dashboard and /admin/config carry @admin_required but /admin/secret-data does not, the asymmetry itself is the vulnerability; I diff the decorators on every route in a blueprint.
  • A 401/403 on one endpoint and a 200 on a neighboring one for the same unauthenticated request. I spray the whole /admin/* and /internal/* namespace with no session and flag anything that does not reject me.
  • Authorization expressed only as per-view decorators with no before_request guard or default-deny, since that design guarantees a forgotten route is reachable.
  • Sensitive data assembled directly in the handler (hardcoded keys, DB passwords) with no role check above it in the function body.

The defender takeaway: enforce authorization centrally with default-deny so a forgotten decorator fails closed instead of open. This is closely related to ownership-level gaps covered in Business Logic Vulnerabilities in Flask Applications.

Proof of exploitation

Run the lab app (PyFuLabs/flask-fu/flask-broken-access-control). One sensitive route, /admin/secret-data, is missing the @admin_required decorator, so it is reachable with no session at all:

curl -s "http://pyfu.local/flask-fu/flask-broken-access-control/admin/secret-data"
{
  "sensitive_data": {
    "api_key": "API-SECRET-123456",
    "db_password": "super-secret-db-pass",
    "internal_token": "internal-token-abcdef"
  }
}

The neighboring /admin/dashboard and /admin/config routes enforce the decorator and return 401; this one simply forgot it.

Mitigation

The fix is to enforce authorization on every sensitive route rather than relying on remembering to add a decorator, since the bug here is one endpoint that omitted @admin_required. Apply access control centrally through a before_request hook or a blueprint-level guard that denies by default and allowlists only the public routes, verify the user’s role server-side on each request, and treat any route that returns privileged data without an explicit, tested authorization check as broken.