Flask Security Checklist
If you're preparing a Flask app for production and need to reduce security risk, this guide gives you a practical hardening checklist. Use it to lock down configuration, transport security, process permissions, and common deployment weak points before launch or during an audit.
Quick Fix / Quick Setup
Apply the highest-impact controls first:
# 1) Ensure Flask is not running in debug mode
export FLASK_CONFIG=production
export FLASK_DEBUG=0
export DEBUG=0
# 2) Generate a strong secret key
python - <<'PY'
import secrets
print(secrets.token_hex(32))
PY
# 3) Restrict Gunicorn/systemd process user
sudo systemctl edit gunicorn
# Add or verify:
# [Service]
# User=www-data
# Group=www-data
# NoNewPrivileges=true
# PrivateTmp=true
# 4) Enable firewall for web traffic only
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
# 5) Check HTTPS and security headers
curl -I https://your-domain.com
# 6) Validate Nginx config before reload
sudo nginx -t && sudo systemctl reload nginx
This covers the most common production gaps first: debug mode, weak secrets, over-privileged processes, unnecessary open ports, and missing HTTPS validation.
What’s Happening
Flask production security depends on multiple layers: app configuration, Gunicorn/systemd isolation, Nginx edge controls, firewall rules, secrets handling, and restricted database access.
Most production security failures come from deployment mistakes, not Flask itself: debug mode left on, exposed .env files, public Gunicorn binds, missing HTTPS, weak upload handling, or excessive service permissions.
Step-by-Step Guide
- Disable development settings
Confirm Flask debug mode is off and do not use the Werkzeug development server in production.bashexport FLASK_CONFIG=production export FLASK_DEBUG=0 export DEBUG=0
In your Flask config:pythonDEBUG = False TESTING = False - Set a strong
SECRET_KEYoutside source control
Generate a key:bashpython - <<'PY' import secrets print(secrets.token_hex(32)) PY
Load it from the environment:pythonimport os SECRET_KEY = os.environ["SECRET_KEY"]
If you need a full setup pattern, use Flask Environment Variables and Secrets Setup. - Move credentials out of the repository
Do not hardcode database URLs, API keys, SMTP passwords, or token secrets.
Example systemd environment file:ini# /etc/myapp/myapp.env SECRET_KEY=replace_me DATABASE_URL=postgresql://appuser:strongpassword@127.0.0.1/mydb
Restrict permissions:bashsudo chown root:www-data /etc/myapp/myapp.env sudo chmod 640 /etc/myapp/myapp.env - Run Gunicorn as a dedicated non-root user
In your systemd service:ini[Service] User=www-data Group=www-data WorkingDirectory=/srv/myapp EnvironmentFile=/etc/myapp/myapp.env ExecStart=/srv/myapp/venv/bin/gunicorn --workers 3 --bind unix:/run/gunicorn.sock wsgi:app NoNewPrivileges=true PrivateTmp=true ProtectSystem=full ProtectHome=true
Reload and restart:bashsudo systemctl daemon-reload sudo systemctl restart gunicorn sudo systemctl status gunicorn --no-pager - Restrict file permissions
Application code should not be world-writable. Secrets files should be readable only by root or the service group.bashsudo find /srv/myapp -type f -perm /o+w sudo chmod 640 /etc/myapp/myapp.env sudo chmod 755 /srv/myapp - Terminate TLS at Nginx
Enforce HTTPS and use a valid certificate.
Example server blocks:nginxserver { listen 80; server_name your-domain.com www.your-domain.com; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name your-domain.com www.your-domain.com; ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; location / { include proxy_params; proxy_pass http://unix:/run/gunicorn.sock; } }
Validate:bashsudo nginx -t && sudo systemctl reload nginx curl -I http://your-domain.com curl -I https://your-domain.com
Also review Flask HTTPS and Domain Checklist. - Enable secure cookie settings
In Flask config:pythonSESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Lax" REMEMBER_COOKIE_SECURE = True REMEMBER_COOKIE_HTTPONLY = True REMEMBER_COOKIE_SAMESITE = "Lax" - Configure proxy awareness correctly
If Nginx terminates TLS, Flask must trust only the expected proxy headers.pythonfrom werkzeug.middleware.proxy_fix import ProxyFix app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
Do this only when Flask is behind your trusted reverse proxy and Gunicorn is not publicly exposed. - Add core Nginx security headers
Add headers in the TLS server block:nginxadd_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
If compatible with your frontend, add a Content Security Policy:nginxadd_header Content-Security-Policy "default-src 'self';" always; - Lock down allowed hosts and domain routing
Use only expectedserver_namevalues and remove default server blocks that can expose unintended apps.bashsudo grep -R "server_name" /etc/nginx/sites-enabled - Restrict network exposure
Expose only 80 and 443 publicly. Bind Gunicorn to a Unix socket or127.0.0.1.bashss -tulpn sudo ufw status verbose
Gunicorn example:bashgunicorn --bind 127.0.0.1:8000 wsgi:app
Preferred:bashgunicorn --bind unix:/run/gunicorn.sock wsgi:app - Harden file uploads
Enforce size limits and sanitize names.
In Flask:pythonMAX_CONTENT_LENGTH = 16 * 1024 * 1024
Use safe filenames:pythonfrom werkzeug.utils import secure_filename filename = secure_filename(upload.filename)
In Nginx:nginxclient_max_body_size 16M;
Store uploads outside executable paths and avoid serving raw user uploads directly unless required. - Secure the database connection
Use least-privilege database users and do not expose the database publicly unless necessary.
Check listening addresses:bashss -tulpn | grep 5432 ss -tulpn | grep 3306
Restrict access with firewall rules and database bind settings. - Install dependency and OS updates
Review outdated Python packages:bashpython -m pip list --outdated pip-audit
Review system packages:bashsudo apt list --upgradable - Add logging and monitoring
Review logs regularly:bashsudo journalctl -u gunicorn -n 100 --no-pager sudo tail -n 100 /var/log/nginx/access.log sudo tail -n 100 /var/log/nginx/error.log
Track:- app exceptions
- repeated 4xx/5xx spikes
- auth failures
- abnormal request rates
- unexpected upload attempts
- Set rate limits and request size limits at Nginx
Example:nginxhttp { limit_req_zone $binary_remote_addr zone=app_limit:10m rate=10r/s; } server { client_max_body_size 16M; location /login { limit_req zone=app_limit burst=20 nodelay; include proxy_params; proxy_pass http://unix:/run/gunicorn.sock; } } - Review admin interfaces and background tools
Protect dashboards, queue monitors, internal APIs, and task runners with authentication and network restrictions. Do not leave admin paths publicly accessible by default. - Back up critical data securely
Ensure backups exist, are access-controlled, and can be restored.
Validation should include:- backup schedule exists
- restore test completed
- backup storage permissions restricted
- encryption used where required
- Scan your deployment
Validate runtime configuration and exposure.bashcurl -I https://your-domain.com ss -tulpn sudo nginx -t pip-audit - Re-run the checklist after every infrastructure or app change
Re-check security after:- Nginx changes
- DNS updates
- new upload features
- CI/CD changes
- service user changes
- added background workers
Common Causes
- Debug mode left enabled → exposes stack traces and unsafe debugger behavior → set production config explicitly and verify runtime values.
SECRET_KEYcommitted to source control or reused across environments → weakens session and token integrity → rotate the key and load it from environment variables.- Gunicorn running as root → increases blast radius if compromised → run under a dedicated service account.
- Gunicorn bound to a public interface → bypasses Nginx protections → bind to a Unix socket or
127.0.0.1. - Missing HTTPS redirect or invalid certificate → breaks transport security and browser trust → fix Nginx TLS config and renewal.
- Environment files readable by all users → secrets can leak locally → restrict ownership and permissions.
- Database port exposed publicly → increases attack surface → restrict inbound access and use least-privilege credentials.
- File uploads stored unsafely → allows malicious file handling or overwrite issues → validate, sanitize, and isolate uploads.
- Missing security headers → reduces browser-side protections → configure headers in Nginx or Flask.
- Outdated dependencies or OS packages → known vulnerabilities remain exploitable → patch regularly and audit installed packages.
Debugging Section
Check actual runtime state, not only static config files.
Validate Flask security settings
python - <<'PY'
from app import app
print('DEBUG=', app.config.get('DEBUG'))
print('SESSION_COOKIE_SECURE=', app.config.get('SESSION_COOKIE_SECURE'))
print('SESSION_COOKIE_HTTPONLY=', app.config.get('SESSION_COOKIE_HTTPONLY'))
print('SECRET_KEY_SET=', bool(app.config.get('SECRET_KEY')))
PY
What to look for:
DEBUG=False- secure cookie flags enabled
SECRET_KEY_SET=True
Inspect Gunicorn systemd configuration
sudo systemctl cat gunicorn
sudo systemctl status gunicorn --no-pager
sudo journalctl -u gunicorn -n 100 --no-pager
What to look for:
User=andGroup=set to a non-root accountEnvironmentFile=points to the correct path- hardening directives are applied
- no startup failures from permissions or missing environment variables
Validate Nginx security configuration
sudo nginx -t
sudo systemctl status nginx --no-pager
sudo grep -R "server_name\|ssl_certificate\|add_header\|client_max_body_size" /etc/nginx/sites-enabled /etc/nginx/nginx.conf
What to look for:
- HTTP to HTTPS redirect exists
- valid certificate paths
- headers are present in the TLS block
- upload/body limits are defined where needed
Test headers and redirects
curl -I http://your-domain.com
curl -I https://your-domain.com
What to look for:
301or308redirect from HTTP to HTTPSStrict-Transport-Securityafter HTTPS is stableX-Content-Type-Options: nosniff- correct
Set-Cookieattributes
Verify listening ports and firewall state
ss -tulpn
sudo ufw status verbose
What to look for:
- only expected public ports exposed
- Gunicorn not listening on a public interface
- database ports not publicly reachable unless explicitly required
Check file and secret permissions
ls -lah /path/to/app
ls -lah /path/to/app/.env
stat /path/to/app/.env
sudo -u www-data test -r /path/to/app/.env && echo readable || echo not-readable
What to look for:
- secrets not world-readable
- app user can read only what it needs
- upload directories are writable only where required
Audit package state
python -m pip list --outdated
pip-audit
sudo apt list --upgradable
What to look for:
- vulnerable Python packages
- pending security updates
- outdated web stack components
Checklist
- Flask debug mode is disabled in production.
-
SECRET_KEYis strong and not committed to version control. - Database credentials and API secrets are loaded from environment variables or a secure secret source.
- Gunicorn runs as a dedicated non-root user.
- systemd service files and environment files have restricted permissions.
- Gunicorn is bound to a Unix socket or localhost, not a public interface unless required.
- UFW or cloud firewall exposes only required ports.
- Nginx is serving a valid TLS certificate.
- HTTP requests redirect to HTTPS.
-
SESSION_COOKIE_SECUREandSESSION_COOKIE_HTTPONLYare enabled. - Security headers are returned by Nginx or the app.
- File upload limits and validation are configured.
- Uploaded files are stored with safe permissions and non-executable paths.
- Database user permissions are limited to application needs.
- Server packages and Python dependencies are updated.
- Access logs and error logs are enabled and reviewed.
- Backups exist and restore testing has been performed.
- No default credentials, sample configs, or unused services remain on the server.
Related Guides
- Flask Environment Variables and Secrets Setup
- Flask Production Checklist (Everything You Must Do)
- Flask HTTPS and Domain Checklist
FAQ
What are the minimum security settings for a Flask production deploy?
SECRET_KEY, run behind Nginx with HTTPS, load secrets from environment variables, run Gunicorn as a non-root user, and restrict public ports.Should I store .env on the server?
Do I need both firewall rules and Nginx restrictions?
How do I verify secure cookies are working?
Set-Cookie headers in browser developer tools or with curl and confirm Secure, HttpOnly, and SameSite attributes are present.Is UFW enough to secure database access?
Final Takeaway
Flask security in production is mostly a deployment discipline problem: disable debug, protect secrets, enforce HTTPS, isolate services, restrict network exposure, and validate the result continuously.
Use this checklist as a release gate and re-run it after every infrastructure or application change.