18 KiB
Owasp — Patterns
OWASP Web Application Security
When to Use
- Security code reviews
- Implementing authentication or authorization
- Handling user input from untrusted sources
- Building or auditing web API endpoints
- Configuring CORS, CSP, or other security headers
- Managing secrets, tokens, or credentials in code
- Setting up rate limiting or brute force protection
When NOT to Use
- General code style or formatting reviews with no security implications
- Non-web applications such as CLI tools, batch scripts, or desktop utilities
- Performance optimization tasks where security is not the concern
- Infrastructure-level security (firewall rules, network segmentation)
Core Patterns
1. Input Validation & Sanitization
Always validate input at the boundary. Use allowlists over denylists.
Python (Pydantic)
# BAD - no validation, accepts anything
@app.post("/users")
async def create_user(request: Request):
data = await request.json()
name = data["name"] # no length check, no type check
email = data["email"] # no format validation
role = data["role"] # user controls their own role
db.execute(f"INSERT INTO users VALUES ('{name}', '{email}', '{role}')")
# GOOD - strict schema validation with Pydantic
from pydantic import BaseModel, EmailStr, Field
from enum import Enum
class UserRole(str, Enum):
viewer = "viewer"
editor = "editor"
class CreateUserRequest(BaseModel):
name: str = Field(min_length=1, max_length=100, pattern=r"^[a-zA-Z\s\-]+$")
email: EmailStr
role: UserRole = UserRole.viewer # default to least privilege
@app.post("/users")
async def create_user(payload: CreateUserRequest):
# Pydantic rejects invalid data before this code runs
db.add(User(name=payload.name, email=payload.email, role=payload.role))
TypeScript (Zod)
// BAD - trusting req.body directly
app.post("/users", (req, res) => {
const { name, email, role } = req.body; // no validation
db.query(`INSERT INTO users VALUES ('${name}', '${email}', '${role}')`);
});
// GOOD - validate with Zod at the boundary
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-zA-Z\s\-]+$/),
email: z.string().email(),
role: z.enum(["viewer", "editor"]).default("viewer"),
});
app.post("/users", (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
// result.data is typed and validated
await prisma.user.create({ data: result.data });
});
File Upload Validation
# GOOD - validate MIME type (not just extension), size, and sanitize filename
import magic
ALLOWED_TYPES = {"image/jpeg", "image/png", "application/pdf"}
MAX_SIZE = 5 * 1024 * 1024 # 5 MB
def validate_upload(file_bytes: bytes, filename: str) -> bool:
if len(file_bytes) > MAX_SIZE:
raise ValueError("File too large")
if magic.from_buffer(file_bytes, mime=True) not in ALLOWED_TYPES:
raise ValueError("Disallowed file type")
if ".." in filename or filename.startswith("."):
raise ValueError("Invalid filename")
return True
2. SQL Injection Prevention
Never concatenate user input into SQL strings. Always use parameterized queries or ORM methods.
Raw SQL (Python)
# BAD - string interpolation creates injection vector
def get_user(user_id: str):
query = f"SELECT * FROM users WHERE id = '{user_id}'"
# Input: "'; DROP TABLE users; --" destroys the table
cursor.execute(query)
# GOOD - parameterized query
def get_user(user_id: str):
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
return cursor.fetchone()
SQLAlchemy (Python)
# BAD - text() with f-string
from sqlalchemy import text
result = session.execute(text(f"SELECT * FROM users WHERE name = '{name}'"))
# GOOD - bound parameters with text()
result = session.execute(text("SELECT * FROM users WHERE name = :name"), {"name": name})
# GOOD - ORM query (automatically parameterized)
user = session.query(User).filter(User.name == name).first()
Prisma (TypeScript)
// BAD - raw query with interpolation
const user = await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE id = '${id}'`);
// GOOD - tagged template (auto-parameterized)
const user = await prisma.$queryRaw`SELECT * FROM users WHERE id = ${id}`;
// GOOD - Prisma client methods (always safe)
const user = await prisma.user.findUnique({ where: { id } });
3. XSS Prevention
Prevent cross-site scripting by encoding output, setting CSP headers, and sanitizing HTML.
Output Encoding
// BAD - renders raw user content as HTML
element.innerHTML = userComment;
// GOOD - use textContent for plain text
element.textContent = userComment;
// GOOD - React auto-escapes by default (don't bypass it)
return <div>{userComment}</div>;
// BAD - dangerouslySetInnerHTML defeats React's protection
return <div dangerouslySetInnerHTML={{ __html: userComment }} />;
Sanitizing HTML When You Must Render It
// GOOD - sanitize with DOMPurify when HTML rendering is required
import DOMPurify from "dompurify";
const cleanHtml = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
ALLOWED_ATTR: ["href", "title"],
});
return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
4. Authentication Patterns
Password Hashing
# BAD - plain text or weak hashing
hashed = hashlib.md5(password.encode()).hexdigest() # trivially crackable
# GOOD - use argon2 (preferred) or bcrypt with proper cost
from passlib.hash import argon2
hashed = argon2.hash(password)
is_valid = argon2.verify(password, hashed)
// GOOD - bcrypt in Node.js
import bcrypt from "bcrypt";
const SALT_ROUNDS = 12;
const hashed = await bcrypt.hash(password, SALT_ROUNDS);
const isValid = await bcrypt.compare(password, hashed);
JWT Best Practices
# BAD - long-lived token, weak secret
token = jwt.encode({"user_id": 1, "exp": datetime.utcnow() + timedelta(days=365)},
"secret123", algorithm="HS256")
# GOOD - short expiry, strong secret, httpOnly cookie delivery
ACCESS_TOKEN_EXPIRY = timedelta(minutes=15)
def create_access_token(user_id: int) -> str:
return jwt.encode(
{"sub": user_id, "exp": datetime.now(timezone.utc) + ACCESS_TOKEN_EXPIRY},
os.environ["JWT_SECRET_KEY"], algorithm="HS256",
)
def set_token_cookie(response: Response, token: str):
response.set_cookie(
key="access_token", value=token,
httponly=True, secure=True, samesite="lax", # not accessible via JS, HTTPS only
max_age=int(ACCESS_TOKEN_EXPIRY.total_seconds()),
)
Session Management Rules
- Set session timeouts (30 minutes idle, 8 hours absolute)
- Regenerate session ID after login to prevent session fixation
- Store sessions server-side (Redis, database), not in cookies
- Clear sessions on logout (
request.session.clear()) - Use
httponly,secure, andsamesite=laxon session cookies
5. Authorization & Access Control
RBAC Pattern
# GOOD - role-based access control with decorator
from enum import Enum
class Role(str, Enum):
admin = "admin"
editor = "editor"
viewer = "viewer"
ROLE_HIERARCHY = {Role.admin: 3, Role.editor: 2, Role.viewer: 1}
def require_role(minimum_role: Role):
def decorator(func):
async def wrapper(request: Request, *args, **kwargs):
user = request.state.user
if ROLE_HIERARCHY.get(user.role, 0) < ROLE_HIERARCHY[minimum_role]:
raise HTTPException(status_code=403)
return await func(request, *args, **kwargs)
return wrapper
return decorator
@app.delete("/posts/{post_id}")
@require_role(Role.editor)
async def delete_post(request: Request, post_id: int): ...
Middleware-Based Authorization (Express)
// GOOD - authorization middleware
function requireRole(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
}
app.delete("/posts/:id", requireRole("admin", "editor"), deletePostHandler);
Object-Level Permissions
# BAD - checks auth but not ownership (any user can edit any document)
@app.put("/documents/{doc_id}")
async def update_document(doc_id: int, payload: UpdateDoc, user=Depends(get_current_user)):
doc = await db.get(Document, doc_id)
doc.content = payload.content
# GOOD - verify ownership or admin role on every mutation
@app.put("/documents/{doc_id}")
async def update_document(doc_id: int, payload: UpdateDoc, user=Depends(get_current_user)):
doc = await db.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404)
if doc.owner_id != user.id and user.role != Role.admin:
raise HTTPException(status_code=403)
doc.content = payload.content
6. CORS Configuration
FastAPI
# BAD - allows everything
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"])
# GOOD - restrictive CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com", "https://staging.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
Express
// BAD
app.use(cors({ origin: true, credentials: true }));
// GOOD - explicit allowlist with callback
const ALLOWED_ORIGINS = ["https://app.example.com"];
app.use(cors({
origin: (origin, cb) => {
if (!origin || ALLOWED_ORIGINS.includes(origin)) cb(null, true);
else cb(new Error("Not allowed by CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE"],
}));
7. Security Headers
Express with Helmet
// GOOD - Helmet sets secure defaults for all critical headers
import helmet from "helmet";
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
frameAncestors: ["'none'"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
}));
FastAPI
# GOOD - security headers middleware
@app.middleware("http")
async def security_headers(request, call_next):
response = await call_next(request)
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none';"
return response
8. Secret Management
# BAD - hardcoded secrets
DATABASE_URL = "postgresql://admin:p@ssw0rd@localhost/mydb"
API_KEY = "sk-1234567890abcdef"
JWT_SECRET = "mysecret"
# GOOD - environment variables with validation
import os
def get_required_env(key: str) -> str:
value = os.environ.get(key)
if not value:
raise RuntimeError(f"Required environment variable {key} is not set")
return value
DATABASE_URL = get_required_env("DATABASE_URL")
API_KEY = get_required_env("API_KEY")
JWT_SECRET = get_required_env("JWT_SECRET")
.env and .gitignore
# .env (NEVER commit this file)
DATABASE_URL=postgresql://admin:securepass@localhost/mydb
JWT_SECRET=a-very-long-random-string-from-openssl-rand
API_KEY=sk-prod-xxxxxxxxxxxx
# .gitignore - always include these
.env
.env.*
!.env.example
*.pem
*.key
credentials.json
Commit a .env.example with empty values to document required variables without exposing secrets.
9. Rate Limiting
Python (FastAPI with slowapi)
# GOOD - rate limiting on sensitive endpoints
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.post("/login")
@limiter.limit("5/minute") # brute force protection
async def login(request: Request, credentials: LoginRequest):
...
@app.post("/api/data")
@limiter.limit("100/minute") # general API rate limit
async def get_data(request: Request):
...
Express (express-rate-limit)
// GOOD - tiered rate limiting
import rateLimit from "express-rate-limit";
const generalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 });
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });
app.use("/api/", generalLimiter);
app.use("/auth/login", authLimiter);
app.use("/auth/register", authLimiter);
10. Dependency Security
# Python - audit dependencies
pip install pip-audit
pip-audit # scan for known vulnerabilities
pip-audit --fix # auto-fix where possible
# Node.js - audit dependencies
npm audit # list vulnerabilities
npm audit fix # auto-fix compatible updates
pnpm audit # pnpm equivalent
# Always commit lock files to ensure reproducible builds
# Python: requirements.txt or poetry.lock
# Node.js: package-lock.json, pnpm-lock.yaml, or yarn.lock
Run npm audit --audit-level=high and pip-audit --strict in CI (e.g., GitHub Actions on every PR and weekly schedule). Treat high-severity findings as build failures.
Best Practices
-
Validate at the boundary, trust nothing inside. Every piece of user input -- query params, headers, request bodies, file uploads -- must be validated before processing. Use Pydantic or Zod schemas, not manual checks.
-
Apply the principle of least privilege everywhere. Default to the most restrictive access. Grant permissions explicitly. Use role-based access control and verify object-level ownership on every mutation.
-
Never store or log secrets in plain text. Use environment variables, a secret manager, or encrypted storage. Ensure secrets never appear in logs, error messages, or version control.
-
Use strong, adaptive password hashing. Always use argon2 or bcrypt with a sufficient work factor. Never use MD5, SHA-1, or SHA-256 alone for password storage.
-
Set security headers on every response. Enable HSTS, CSP, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy. Use Helmet for Express and middleware for FastAPI.
-
Fail closed, not open. When authentication or authorization checks encounter errors, deny access by default. Never fall through to an unprotected code path on exception.
-
Keep dependencies updated and audited. Run
npm auditandpip-auditin CI pipelines. Pin dependency versions with lock files. Review changelogs before major upgrades. -
Enforce rate limiting on all public-facing endpoints. Apply stricter limits on authentication and password reset endpoints. Use IP-based and account-based limiting together for defense in depth.
Common Pitfalls
-
Trusting client-side validation alone. Attackers bypass browser validation trivially. Always re-validate on the server.
-
Using wildcard CORS with credentials.
allow_origins=["*"]with credentials is insecure and browsers reject it. Specify exact origins. -
Storing JWTs in localStorage. Any XSS can steal them. Use httpOnly, secure, sameSite cookies instead.
-
Returning detailed error messages in production. Stack traces help attackers. Return generic messages to clients, log details server-side.
-
Using ORM raw query methods unsafely.
$queryRawUnsafeandtext()with f-strings bypass ORM protections. Audit every raw SQL call. -
Checking authentication but not authorization. "Logged in" does not mean "authorized." Check object-level permissions on every write.
-
Disabling security in dev and shipping it. CSP, CORS, HTTPS disabled for convenience can reach production. Use environment-aware config.
-
Ignoring dependency vulnerabilities. Known CVEs in transitive deps are a top attack vector. Automate auditing in CI.
Security Review Checklist
- All user input validated with schema (Pydantic / Zod) before processing
- No string concatenation or interpolation in SQL queries
- Passwords hashed with argon2 or bcrypt (never MD5/SHA)
- JWTs have short expiry, use httpOnly cookies, strong secret from env
- Authorization checked at object level, not just authentication
- CORS configured with explicit origin allowlist (no wildcards with credentials)
- Security headers set: CSP, HSTS, X-Content-Type-Options, X-Frame-Options
- No secrets hardcoded in source -- all from environment variables
- .env files listed in .gitignore, .env.example committed
- Rate limiting applied to login, registration, and password reset endpoints
- File uploads validated by MIME type, size, and sanitized filename
- Error responses do not leak stack traces or internal details
- Dependencies audited with npm audit / pip-audit (no high-severity CVEs)
- HTTPS enforced in production with HSTS preload
- No use of eval(), dangerouslySetInnerHTML (without DOMPurify), or innerHTML
Related Skills
docker— Container security hardeningdefense-in-depth— Multi-layer security validation