Security essentials for Flask APIs — CORS, Talisman security headers, rate
99
94%
Does it follow best practices?
Impact
100%
1.17xAverage score across 10 eval scenarios
Passed
No known issues
Production security defaults every Flask app needs.
Behind nginx, AWS ALB, or any reverse proxy, Flask sees the proxy IP as the client IP. This breaks rate limiting and logging.
# RIGHT — ProxyFix so get_remote_address returns real client IP
from werkzeug.middleware.proxy_fix import ProxyFix
def create_app():
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
...# WRONG — no ProxyFix, rate limiter treats all requests as one client
limiter = Limiter(key_func=get_remote_address) # always returns 127.0.0.1Without ProxyFix, every user shares the same rate-limit bucket.
pip install flask-limiter[redis]from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
# RIGHT — Redis backend, survives restarts, shared across workers
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200 per hour"],
storage_uri="redis://localhost:6379/0",
)# WRONG — in-memory storage (default), each Gunicorn worker has its own counter
limiter = Limiter(key_func=get_remote_address, default_limits=["200 per hour"])
# 4 workers = effective limit is 800/hour, counters reset on restartPer-route limits on mutation endpoints:
@orders_bp.route('/orders', methods=['POST'])
@limiter.limit("10 per minute")
def create_order():
...
@orders_bp.route('/orders/<int:order_id>', methods=['DELETE'])
@limiter.limit("5 per minute")
def cancel_order(order_id):
...pip install flask-talismanfrom flask_talisman import Talisman
talisman = Talisman()
# RIGHT — force_https=False when proxy handles TLS termination
talisman.init_app(app,
content_security_policy=None, # Configure CSP based on your frontend
force_https=False, # Proxy handles HTTPS; True causes redirect loops
session_cookie_secure=True, # Always True in production
session_cookie_http_only=True,
)# WRONG — force_https=True behind nginx/ALB causes infinite redirects
talisman.init_app(app, force_https=True) # proxy -> HTTP -> Flask -> redirect -> proxy -> ...Talisman adds: X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security.
Flask sessions are signed but NOT encrypted — anyone can base64-decode the cookie and read its contents.
# RIGHT — secure cookie flags and no sensitive data in session
app.config.update(
SESSION_COOKIE_SECURE=True, # Only sent over HTTPS
SESSION_COOKIE_HTTPONLY=True, # Not accessible via JavaScript
SESSION_COOKIE_SAMESITE='Lax', # CSRF protection for top-level navigations
)
# Never store sensitive data in session — it's readable
session['user_id'] = 42 # OK — non-secret identifier
session['role'] = 'admin' # BAD — attacker can see this
session['api_token'] = 'sk-...' # BAD — token exposed in cookie# WRONG — default SESSION_COOKIE_SECURE=False allows session hijacking over HTTP
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
# Forgot to set SESSION_COOKIE_SECURE=Truepip install flask-corsfrom flask_cors import CORS
cors = CORS()
# RIGHT — explicit origins from environment
cors.init_app(app, origins=os.getenv('ALLOWED_ORIGINS', 'http://localhost:5173').split(','))# WRONG — wildcard allows any website to make authenticated requests
CORS(app, resources={r"/*": {"origins": "*"}})# RIGHT — strong key from environment
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
if not app.config['SECRET_KEY'] and not app.config.get('TESTING'):
raise RuntimeError('SECRET_KEY environment variable is required')
# Generate a strong key: python -c "import secrets; print(secrets.token_hex(32))"# WRONG — weak or hardcoded key
app.config['SECRET_KEY'] = 'my-secret' # Guessable
app.config['SECRET_KEY'] = 'dev-secret-key-1234' # Hardcoded in source@orders_bp.route('/orders', methods=['POST'])
def create_order():
data = request.get_json(silent=True)
if data is None:
return jsonify({'error': 'Request body must be JSON'}), 400
customer_name = data.get('customer_name', '').strip()
if not customer_name:
return jsonify({'error': 'customer_name is required'}), 400
if len(customer_name) > 100:
return jsonify({'error': 'customer_name too long'}), 400
...Use get_json(silent=True) — without silent=True, Flask returns 400 with an HTML error page instead of your JSON error.
# RIGHT — generic JSON errors, no information leakage
@app.errorhandler(500)
def internal_error(e):
app.logger.exception('Internal server error')
return jsonify({'error': 'Internal server error'}), 500
@app.errorhandler(404)
def not_found(e):
return jsonify({'error': 'Not found'}), 404# WRONG — leaks stack trace or internal details
@app.errorhandler(500)
def internal_error(e):
return jsonify({'error': str(e)}), 500 # Exposes exception message# NEVER in production — exposes interactive debugger (RCE vulnerability)
app.debug = False
# Or just don't set it — default is False*)