CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/flask-security-basics

Security essentials for Flask APIs — CORS, Talisman security headers, rate

99

1.17x
Quality

94%

Does it follow best practices?

Impact

100%

1.17x

Average score across 10 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/flask-security-basics/

name:
flask-security-basics
description:
Security essentials for Flask APIs — CORS, Talisman security headers, rate limiting, CSRF protection, and input validation. Use when building or reviewing Flask apps before production deployment, or when a security review flags missing protections.
keywords:
flask security, flask cors, flask talisman, flask rate limiting, flask csrf, flask input validation, flask limiter, flask production, flask security headers, flask best practices security
license:
MIT

Flask Security Basics

Production security defaults every Flask app needs.


1. Reverse Proxy Setup (ProxyFix)

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

Without ProxyFix, every user shares the same rate-limit bucket.


2. Rate Limiting (Flask-Limiter) — Production Storage

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 restart

Per-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):
    ...

3. Talisman — Correct Settings Behind a Proxy

pip install flask-talisman
from 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.


4. Session Cookie 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=True

5. CORS (Flask-CORS)

pip install flask-cors
from 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": "*"}})

6. Secret Key

# 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

7. Input Validation

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


8. Error Handlers

# 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

9. Debug Mode

# NEVER in production — exposes interactive debugger (RCE vulnerability)
app.debug = False
# Or just don't set it — default is False

Checklist

  • ProxyFix applied when behind a reverse proxy
  • Flask-Limiter with Redis/memcached storage backend (not in-memory)
  • Rate limits on POST/PATCH/DELETE routes stricter than global default
  • Flask-Talisman with force_https=False behind proxy
  • SESSION_COOKIE_SECURE=True in production
  • SESSION_COOKIE_HTTPONLY=True
  • No sensitive data stored in Flask session (signed, not encrypted)
  • Flask-CORS with explicit origins (not *)
  • SECRET_KEY from environment, generated with secrets.token_hex(32)
  • Input validated with get_json(silent=True) + type checks
  • Error handlers return generic JSON, no stack traces
  • debug=False in production

References

  • Flask-Limiter storage configuration — why in-memory storage fails with multiple workers
  • Werkzeug ProxyFix — extracting real client IP behind proxies
  • Flask-Talisman — security headers and HTTPS enforcement
  • Flask session security — cookie flags and signed vs encrypted sessions
  • Flask-CORS docs — CORS configuration

Verifiers

  • flask-security — Flask security best practices verifier

skills

flask-security-basics

tile.json