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_ENV=production
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_ENV=production 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 python -m 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 python -m 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
python -m 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
Q: What are the minimum security settings for a Flask production deploy?
A: Disable debug, use a strong SECRET_KEY, run behind Nginx with HTTPS, load secrets from environment variables, run Gunicorn as a non-root user, and restrict public ports.
Q: Should I store .env on the server?
A: Only if necessary, with strict permissions and limited ownership. A secrets manager or protected systemd environment file is safer.
Q: Do I need both firewall rules and Nginx restrictions?
A: Yes. Nginx protects at the web layer; firewall rules reduce exposed network surface.
Q: How do I verify secure cookies are working?
A: Inspect Set-Cookie headers in browser developer tools or with curl and confirm Secure, HttpOnly, and SameSite attributes are present.
Q: Is UFW enough to secure database access?
A: It helps, but also restrict access in cloud security groups, database bind settings, and database user privileges.
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.