| name | security-practices |
| category | Foundations |
| description | MUST USE when writing or reviewing code that handles user input, authentication, authorization, API endpoints, database queries, secrets, or any security-sensitive functionality. Enforces OWASP Top 10 prevention, secure defaults, and defense-in-depth patterns. |
Security Best Practices
Input Validation — Never Trust the Client
@app.post("/users")
async def create_user(data: dict):
db.execute(f"INSERT INTO users (email) VALUES ('{data['email']}')")
class UserCreate(BaseModel):
email: EmailStr
username: str = Field(min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
@app.post("/users")
async def create_user(data: UserCreate):
await user_service.create(data)
const UserCreate = z.object({
email: z.string().email(),
username: z.string().min(3).max(64).regex(/^[a-zA-Z0-9_-]+$/),
});
app.post("/users", (req, res) => {
const data = UserCreate.parse(req.body);
});
Allowlist Over Denylist
for bad in ["<script>", "DROP TABLE"]:
value = value.replace(bad, "")
class SortParams(BaseModel):
sort_by: Literal["created_at", "updated_at", "name", "price"]
order: Literal["asc", "desc"] = "asc"
SQL Injection Prevention
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute("SELECT * FROM users WHERE username = %s", (username,))
user = await db.execute(select(User).where(User.username == username))
const query = `SELECT * FROM users WHERE id = ${userId}`;
await db.query("SELECT * FROM users WHERE id = $1", [userId]);
Never interpolate column/table names from user input — validate against an allowlist.
XSS Prevention
return HTMLResponse(f"<h1>Hello {name}</h1>")
return templates.TemplateResponse("greet.html", {"request": request, "name": name})
Security Headers
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = "default-src 'self'"
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains"
import helmet from "helmet";
app.use(helmet());
Authentication
Hash Passwords with bcrypt
hashed = hashlib.md5(password.encode()).hexdigest()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
hashed = pwd_context.hash(password)
is_valid = pwd_context.verify(plain_password, hashed)
JWT — Short Expiry, Pinned Algorithm
token = jwt.encode({"user_id": 1}, "secret")
data = jwt.decode(token, "secret", algorithms=["HS256", "none"])
token = jwt.encode(
{"sub": str(user.id), "exp": datetime.now(timezone.utc) + timedelta(minutes=15)},
settings.jwt_secret, algorithm="HS256",
)
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
Generic Auth Errors
if not user: raise HTTPException(400, "User not found")
if not verify_password(...): raise HTTPException(400, "Incorrect password")
if not user or not verify_password(password, user.hashed_password):
raise HTTPException(401, "Incorrect username or password")
Authorization
@router.get("/orders/{order_id}")
async def get_order(order_id: int, db: DBSession):
return await db.get(Order, order_id)
@router.get("/orders/{order_id}")
async def get_order(order_id: int, db: DBSession, user: CurrentUser):
order = await db.execute(
select(Order).where(Order.id == order_id, Order.user_id == user.id)
)
if not (order := order.scalar_one_or_none()):
raise HTTPException(status_code=404)
return order
def require_role(*roles: str):
async def check(current_user: CurrentUser):
if current_user.role not in roles:
raise HTTPException(status_code=403, detail="Forbidden")
return current_user
return check
AdminUser = Annotated[User, Depends(require_role("admin"))]
Secrets
JWT_SECRET = "super-secret-key-123"
class Settings(BaseSettings):
jwt_secret: str
database_url: str
model_config = SettingsConfigDict(env_file=".env")
.env
*.pem
*.key
credentials.json
Error Handling — Never Leak Internals
return JSONResponse(status_code=500, content={"detail": str(exc)})
logger.exception("Unhandled error", exc_info=exc)
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
CORS
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True)
app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"],
allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"])
Cookies
response.set_cookie(key="session", value=token,
httponly=True, secure=True, samesite="lax", max_age=3600)
Rules
- Validate all input server-side — Pydantic, Zod, or equivalent
- Allowlist over denylist — validate against known-good values
- Parameterize all queries — never interpolate user input into SQL
- Escape all output — auto-escaping templates, CSP header
- Hash passwords with bcrypt/argon2 — never MD5/SHA/plain text
- Pin JWT algorithm, set short expiry — never allow
"none"
- Generic auth errors — never reveal which field was wrong
- Scope queries to authenticated user — prevent IDOR
- Never hardcode secrets — env variables,
.env in .gitignore
- Never leak internal errors — log details, return generic messages
- Explicit CORS origins — never
"*" with credentials
- Secure cookie flags —
httponly, secure, samesite
Anti-Rationalizations
| Excuse | Rebuttal |
|---|
| "It's an internal tool, doesn't need validation" | Internal tools get exposed — VPN drops, SSRF, accidental public deploy. Validate at every boundary. |
| "The frontend already validates this" | Client validation is UX, not security. Anyone can hit the API directly with curl. |
| "We'll add auth before launch" | Auth bolted on late misses object-level checks (IDOR). Build it in from the first endpoint. |
| "String interpolation is fine, the input is trusted" | "Trusted" is a vibe, not a guarantee. Parameterize every query, every time — no exceptions. |
| "Logging the full error helps debugging" | And leaks stack traces, paths, and secrets to attackers. Log details server-side, return generic messages. |
"We need CORS * for the public API" | Then it can't use credentials. allow_origins=["*"] + allow_credentials=True is a critical bug. |
| "MD5 is fine, we just need a quick hash" | Not for passwords, not for tokens, not for anything security-adjacent. Use bcrypt/argon2 or a real KDF. |