Flask
Security
Explicit code is verifiably secure code.
No hidden abstractions. No magic. Just safety you can read.
Security you can read is security you can trust.
One of the underappreciated advantages of Flask's explicit style is security verification. When your SQL is written directly in your code, you can see whether it's parameterised. When your session is managed explicitly, you can read how it works.
With ORM-heavy frameworks, security depends on the abstraction layer doing the right thing — and you can't verify that without running the code and inspecting SQL logs. Flask's transparency is a security advantage, not a liability.
1. SQL Injection
SQL injection is the #1 database vulnerability. Flask + psycopg2 makes it impossible by convention when you use parameterised queries — which is the default way Flask Vibe writes SQL.
Vulnerable — Never do this
# DO NOT DO THIS — string interpolation = SQL injection risk
email = request.form['email']
cur.execute(f"SELECT * FROM users WHERE email = '{email}'")
A malicious user can enter: ' OR '1'='1 and bypass authentication entirely.
Safe — Always use parameterised queries
# DO THIS — psycopg2 escapes the value automatically
email = request.form['email']
cur.execute("""
SELECT * FROM users WHERE email = %s
""", (email,)) # ← The tuple is the key. psycopg2 escapes it safely.
psycopg2 escapes the value before it touches the query. No injection possible. And you can read this and know it's safe.
The Verification Gap: Flask vs ORM
Flask + psycopg2
cur.execute("""
SELECT * FROM users
WHERE email = %s
""", (email,))
You see the query. You see the %s. You know it's parameterised.
Security is visually verifiable.
Next.js + Prisma
const user = await prisma.user.findFirst({
where: { email: email }
})
Prisma does parameterise — but you cannot see this in the code. You must trust the ORM. If Prisma has a bug, you're exposed without knowing.
Flask Vibe Rule #1
Always use %s placeholders and pass values as a tuple.
Never use f-strings or string concatenation in SQL queries. This is the only rule. It's easy to follow. It's easy to verify.
2. CSRF Protection
Cross-Site Request Forgery (CSRF) tricks authenticated users into submitting requests they didn't intend to. Flask doesn't include CSRF protection by default — but adding it is a one-line install.
pip install flask-wtf
from flask import Flask
from flask_wtf.csrf import CSRFProtect
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ['SECRET_KEY']
csrf = CSRFProtect(app)
# Done. ALL POST routes are now CSRF-protected.
<form method="POST" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="email" name="email">
<input type="password" name="password">
<button type="submit">Login</button>
</form>
CSRF and Next.js
Next.js API routes using JSON bodies aren't vulnerable to traditional CSRF (browsers won't send
cross-origin application/json requests without CORS). But the moment you use cookies for
session authentication in Next.js, you need CSRF protection too. The same rule applies.
3. Session Security
Flask uses signed cookies for sessions by default. The session data is stored
client-side in the cookie, but it's cryptographically signed with your SECRET_KEY.
This means users can read the session cookie but cannot tamper with it.
import os
from flask import Flask, session
app = Flask(__name__)
# CRITICAL: use a long, random, secret key — never hardcode it
app.config['SECRET_KEY'] = os.environ['SECRET_KEY']
# Set session to expire when browser closes
app.config['SESSION_COOKIE_HTTPONLY'] = True # JS can't read the cookie
app.config['SESSION_COOKIE_SECURE'] = True # HTTPS only (set in production)
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # CSRF mitigation
@app.route('/login', methods=['POST'])
def login():
email = request.form['email']
password = request.form['password']
cur.execute("""
SELECT id, password_hash FROM users WHERE email = %s
""", (email,))
user = cur.fetchone()
if user and bcrypt.checkpw(password.encode(), user['password_hash'].encode()):
session['user_id'] = user['id'] # Signed by SECRET_KEY
return redirect('/dashboard')
flash('Invalid credentials', 'error')
return redirect('/login')
Generating a proper SECRET_KEY
# Run this once in Python to generate a secure key:
import secrets
print(secrets.token_hex(32))
# → 'a4f3d8e2c1b0....' (64-char hex string)
# Paste this into your Railway/Render environment variables as SECRET_KEY
Never Do This
# ❌ Hardcoded secret key — anyone who reads your code can forge sessions
app.config['SECRET_KEY'] = 'mysecretkey'
app.config['SECRET_KEY'] = 'dev'
app.config['SECRET_KEY'] = 'changeme'
These are real values seen in public GitHub repos. If your SECRET_KEY is public,
attackers can forge any session they want. Always read it from an environment variable.
4. Input Validation
Flask doesn't validate input automatically — you write explicit validation. This is a feature. Your validation logic is visible and testable, not hidden in a schema file.
@app.route('/register', methods=['POST'])
def register():
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '').strip()
name = request.form.get('name', '').strip()
# Validate — explicit, readable, easy to audit
errors = []
if not email or '@' not in email or '.' not in email:
errors.append('Valid email address required')
if not password or len(password) < 8:
errors.append('Password must be at least 8 characters')
if not name or len(name) < 2:
errors.append('Name must be at least 2 characters')
if errors:
for error in errors:
flash(error, 'error')
return redirect('/register')
# Hash password before storing — never store plaintext
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
# Insert safely with parameterised query
try:
cur.execute("""
INSERT INTO users (email, password_hash, name, created_at)
VALUES (%s, %s, %s, NOW())
RETURNING id
""", (email, password_hash, name))
conn.commit()
except Exception:
flash('Email already registered', 'error')
return redirect('/register')
return redirect('/dashboard')
Every validation rule is in plain sight. A code reviewer can verify it in 30 seconds. An AI assistant can modify it without understanding a complex schema system. This is what "transparency over magic" looks like in practice.
Pre-Deploy Security Checklist
Run through this before every production deployment.
All SQL uses parameterised queries (%s placeholders)
Search your codebase for f"SELECT and "SELECT { — none should exist.
SECRET_KEY is a long random string from environment variable
Generate with secrets.token_hex(32). Never commit it to git.
Passwords hashed with bcrypt before storage
Never store plaintext passwords. bcrypt.hashpw() before INSERT.
CSRF protection enabled on all state-changing forms
flask-wtf with CSRFProtect(app) and {{ csrf_token() }} in every form.
Session cookie flags set: HTTPONLY, SECURE, SAMESITE=Lax
Prevents JavaScript access, MITM attacks, and CSRF via cookie.
All user inputs validated and stripped before use
Use .strip() on strings. Validate email format, length limits, required fields.
Jinja2 auto-escaping active (it is by default)
Flask's Jinja2 escapes HTML by default — XSS protection is built in. Only use | safe for trusted content.
.env file is in .gitignore
Database URL, API keys, and secret key must never be committed to git.
Security You Can Read.
Security You Can Trust.
Flask's explicitness is its security model. What you see is what runs.