Security Review
Table of Contents
Why a Security Review Matters
When you reach the end of a project, it is tempting to ship as soon as things “work.” For a production backend, that is not enough. You must also ask, “Is this safe to put on the internet?”
A security review is a focused, systematic check of your application, its configuration, and its infrastructure, from a security perspective. In this chapter you will:
- Learn how to approach a structured review.
- Walk through a checklist that covers common risks.
- See concrete examples of what to look for and how to fix it in a typical FastAPI + PostgreSQL + Redis setup.
You will not become a professional security auditor from one chapter, but you will gain a repeatable process that significantly reduces obvious and common risks.
Goal of a security review:
Before going to production, you must actively try to find ways your system can be abused, attacked, or misconfigured, and fix or mitigate them, instead of assuming it is safe because “tests pass.”
A Practical Security Review Framework
You can structure your security review into several layers:
- Code and dependencies
- Authentication and authorization
- Data validation and input handling
- Secrets and configuration
- Transport security and network configuration
- Database and storage
- Operational security (logging, monitoring, backups, recovery)
Think of these as a checklist you can go through, line by line, for your final project.
A simple way to track your review is a table like this:
| Area | Check | Status | Notes / Fix |
|---|---|---|---|
| Auth | Passwords hashed with strong algorithm | Done | Using Argon2 |
| Secrets | No secrets in Git | Needs fix | Remove .env from repo |
| HTTP | All traffic forced to HTTPS | Pending | Configure redirect in Nginx |
| DB | App user has least privileges | Done | No SUPERUSER |
You can keep this as a markdown file in the repo, for example SECURITY_REVIEW.md.
1. Code and Dependency Security
Even if your own code is careful, your dependencies can introduce security risks. You also want to avoid easy coding mistakes.
Remove Insecure Debug Settings
Look for anything you enabled during development that should be disabled in production.
Examples:
- Auto reloading servers.
- Detailed error pages with stack traces.
- Hard coded test users or backdoors.
In a FastAPI + Uvicorn setup, you might have:
uvicorn app.main:app --reload
In production, remove the --reload flag, and run behind Gunicorn or another process manager.
If you used custom config:
# config.py
DEBUG: bool = True # DevelopmentEnsure the production config has:
DEBUG: bool = FalseAnd in your app, avoid exposing debug data:
if settings.DEBUG:
# Maybe log extra details
...Avoid Leaking Sensitive Data in Logs and Errors
Search your code for print, logger.debug, and exception handlers.
Bad examples:
logger.info(f"Login attempt for {email} with password {password}")except Exception as e:
return JSONResponse(
status_code=500,
content={"error": str(e)}, # Might leak SQL, file paths, etc.
)Better:
logger.info("Login attempt for %s", email) # No password
except Exception:
logger.exception("Unhandled server error")
return JSONResponse(
status_code=500,
content={"error": "Internal server error"},
)Never log or return in responses:
- Passwords or password hashes.
- Access tokens, refresh tokens, API keys.
- Full SQL queries with parameter values.
- Full stack traces in production responses.
Dependency Vulnerabilities
Use tools to scan for known vulnerabilities in Python packages and system dependencies.
Typical tools:
pip-auditfor Python:
pip install pip-audit
pip-auditpip list --outdatedto see outdated packages.
Review any reported vulnerabilities and upgrade or replace packages where needed. For example, if pip-audit reports an issue in an old requests version, update:
pip install "requests>=2.32.0"
Lock dependencies with a requirements.txt or poetry.lock and avoid arbitrary version drift.
2. Authentication and Authorization Review
For the final project, you already implemented auth. Now you must verify it is robust and correctly enforced.
Password Storage and Login Flow
Check your registration and login code.
Bad example:
# Never do this
user.password = password # Plain text
def verify_password(plain, stored):
return plain == storedCorrect style:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)Confirm:
- Passwords are always stored hashed in the database.
- You use a strong algorithm such as Argon2, bcrypt, or scrypt.
- You do not reuse password hashes as tokens for anything else.
JWT and Session Security
If you use JWT for auth, review:
- Signing algorithm: Use
HS256orRS256and nevernone. - Secret key: Long, random, and kept outside the repo.
- Expiration: Access tokens must expire in a reasonable time, for example 15 minutes.
Example in FastAPI:
from datetime import datetime, timedelta
import jwt
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode, settings.JWT_SECRET_KEY, algorithm="HS256"
)
return encoded_jwtVerify that:
- You validate
expand reject expired tokens. - You do not trust user-supplied
subor roles without verifying the signature. - Blacklist or rotate tokens when necessary, for example on password reset.
Enforce Authorization Everywhere
Find all routes and ensure they have proper permission checks. Look for any @app.get or @app.post that should be protected but are missing auth.
For example, in FastAPI:
@app.get("/admin/users")
def list_users(current_user: User = Depends(get_current_admin_user)):
...
Review get_current_admin_user:
- Ensure it fails if
user.role != "admin". - Ensure it is used for all admin-only routes.
Also check resource ownership. For example, retrieving an order:
@app.get("/orders/{order_id}")
def get_order(
order_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
order = db.get(Order, order_id)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not allowed")
return orderCommon authorization mistakes:
- Relying only on frontend checks.
- Checking only that a user is authenticated, not that they own the resource.
- Having one admin route without the admin check because you forgot the dependency.
3. Input Validation and Common Web Vulnerabilities
Your backend must never trust user input. Carefully validate and sanitize it.
Request Validation
Use Pydantic models consistently and set strict types.
Example registration model:
from pydantic import BaseModel, EmailStr, constr
class RegisterUser(BaseModel):
email: EmailStr
password: constr(min_length=8, max_length=128)
full_name: constr(strip_whitespace=True, min_length=1, max_length=100)
Do not accept loose dict or Any types without validation.
Ensure you:
- Validate lengths for strings.
- Validate numeric ranges for integers and floats.
- Reject unexpected fields where possible (e.g. use extra = "forbid" in Pydantic if appropriate).
Protect Against SQL Injection
Use your ORM or parameterized queries. Never concatenate user input into SQL.
Bad:
user_id = request.query_params["user_id"]
query = f"SELECT * FROM users WHERE id = {user_id};"
db.execute(query)Good (SQLAlchemy):
user = db.query(User).filter(User.id == user_id).first()Or, if you use raw SQL, always use parameters:
db.execute(text("SELECT * FROM users WHERE id = :id"), {"id": user_id})Prevent Cross Site Scripting (XSS)
On a pure API backend, XSS is mostly a concern in:
- HTML templates for emails or admin panels.
- Any endpoint that renders HTML.
Rules:
- Escape user content in templates. Modern template engines do this by default.
- Never inject raw HTML from user input without sanitizing it.
- If you must allow some HTML, use a sanitizing library to allow only safe tags.
Example in Jinja2, by default:
<p>{{ user_comment }}</p> <!-- Escaped by default -->
If you disable escaping (|safe), you must be absolutely sure the content is sanitized.
Prevent CSRF (Cross Site Request Forgery)
For a JSON API with JWT and Authorization: Bearer headers, CSRF risk is lower. CSRF mainly affects cookie based auth.
If your final project uses cookies for auth:
- Use SameSite cookies when possible:
SameSite=LaxorStrict. - Use CSRF tokens on state changing endpoints (POST, PUT, PATCH, DELETE).
- Verify the CSRF token on the server.
FastAPI example using a header for CSRF:
- Server sends a
X-CSRF-Tokenin a cookie or response. - Client sends this token in a header
X-CSRF-Tokenfor unsafe operations. - Server compares the two.
Limit Payload Sizes and File Uploads
For file uploads and large JSON bodies, set limits at your app or reverse proxy.
Examples:
- Nginx config:
client_max_body_size 10M;- FastAPI dependency to reject very large JSON:
from fastapi import Request, HTTPException
MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB
async def enforce_body_size(request: Request):
body = await request.body()
if len(body) > MAX_BODY_SIZE:
raise HTTPException(status_code=413, detail="Payload too large")Add this dependency to routes that receive large bodies.
For uploaded files:
- Validate file type and extension.
- Do not execute or render uploaded files as code or templates.
- Store uploaded files outside of the code directory.
4. Secrets and Configuration
You configured environment variables in earlier chapters. Now check that none of them are exposed or mishandled.
No Secrets in the Repository
Search for patterns like:
SECRETPASSWORDKEYTOKENAWS_
Look in:
.pyfiles.envfilesdocker-compose.yml- CI/CD configs
Bad:
JWT_SECRET_KEY = "supersecret123" # Hard coded
DATABASE_URL = "postgres://user:password@host/db"Good:
import os
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")
DATABASE_URL = os.getenv("DATABASE_URL")Ensure:
.envis in.gitignore.- Sample values live in
.env.examplewithout real secrets.
If you accidentally committed a secret:
- Rotate the secret (new key, new password).
- Remove it from code.
- If public, treat the old value as compromised.
Secure Environment Variables in Production
Check how secrets are stored on the server or orchestrator:
- Docker Compose: use
.envfile not committed to Git. - Kubernetes: use
Secretsobjects. - Cloud providers: use their secrets manager.
Avoid passing secrets via command line arguments or storing them in shell history.
Rule:
All sensitive configuration values (DB passwords, JWT keys, SMTP credentials, API keys) must come from secure environment variables or a dedicated secrets manager, never from source code or public files.
5. Transport Security and Network Configuration
You must protect data in transit and minimize exposed services.
Enforce HTTPS
Check that:
- Your reverse proxy (for example Nginx, Traefik) has a valid TLS certificate.
- HTTP is redirected to HTTPS.
- The application is not directly exposed on port 8000 without TLS.
Example Nginx redirect:
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}Disable Insecure Protocols and Ciphers
Use a reasonable TLS configuration. For example, with Nginx you might use:
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;You can use online generators or guides from Mozilla to pick secure defaults.
Limit Network Exposure
Review what ports are open:
- The database (PostgreSQL) should not be publicly accessible.
- Redis should be bound only to localhost or the internal Docker network.
- Only the reverse proxy (Nginx) should be exposed on the public ports 80 and 443.
Examples:
- PostgreSQL config
postgresql.conf:
listen_addresses = 'localhost'or in Docker, only expose port to other containers, not the internet.
- Redis config:
bind 127.0.0.1
protected-mode yesCheck your firewall (ufw, security groups, etc.). For a simple single server:
| Port | Service | Exposure |
|---|---|---|
| 22 | SSH | Restricted to your IP if possible |
| 80 | HTTP | Public (for redirect to HTTPS) |
| 443 | HTTPS | Public |
| 5432 | PostgreSQL | Private (local only / VPC only) |
| 6379 | Redis | Private (local only / VPC only) |
6. Database and Data Security
Your database contains your most sensitive data. Protect it carefully.
Principle of Least Privilege
The application should use a database user with only the privileges it needs.
Bad:
CREATE USER app_user WITH SUPERUSER PASSWORD '...';Good:
CREATE USER app_user WITH PASSWORD '...';
GRANT CONNECT ON DATABASE mydb TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;Ensure:
- Migrations, if needed, use a higher privileged user but not from the application itself.
- The app cannot create new databases or drop them.
Encrypt Sensitive Columns Where Appropriate
For very sensitive data (for example payment tokens, personal identifiers beyond emails), consider additional encryption at the application level.
For example, you might:
- Encrypt before storing using a key from a secrets manager.
- Decrypt only when needed.
Example pattern:
from cryptography.fernet import Fernet
cipher = Fernet(settings.DATA_ENCRYPTION_KEY)
def store_sensitive(data: str) -> str:
return cipher.encrypt(data.encode()).decode()
def load_sensitive(encrypted: str) -> str:
return cipher.decrypt(encrypted.encode()).decode()
Use this for fields like ssn or credit_card_token if you have them. For many projects, avoiding such data entirely is best.
Backups and Data Protection
Check:
- Automated backups exist for your production database.
- Backup frequency and retention are appropriate (for example daily backups kept for 30 days).
- Backups are stored securely and preferably encrypted.
- You have tested restore at least once on a staging database.
Also verify that logs or backups do not contain secrets in plain text beyond what is strictly necessary.
7. Operational Security: Logging, Monitoring, and Incident Handling
Security is not a one time event. You need visibility and a plan for when things go wrong.
Logging Securely
You want enough logs to investigate incidents, but not too much that you store sensitive data.
Check:
- Every important action (logins, failed logins, password resets, role changes, admin actions) is logged with:
- Timestamp
- User ID or account identifier
- IP address (if available)
- Action type, not raw details
- Logs are structured (JSON) where possible for easier analysis.
Example structured log:
logger.info(
"user_login_failed",
extra={"email": email, "ip": client_ip, "reason": "invalid_password"},
)Ensure log files:
- Are rotated to avoid disk filling up.
- Have appropriate permissions.
- Do not leave old logs publicly accessible.
Monitoring and Alerts
From the monitoring chapter you saw tools like Prometheus or external services. For security review, check:
- Do you have uptime monitoring on the API endpoint?
- Do you have error rate alerts, for example spikes in 5xx responses?
- Do you have some alert for:
- Many failed login attempts.
- Sudden spikes in traffic from one IP.
Even a basic setup helps. For example, configure:
- A simple rate limiting middleware that logs when it blocks IPs.
- A dashboard for key metrics.
Rate Limiting and Abuse Protection
Review your rate limiting:
- Do login endpoints have stricter rate limits than public endpoints?
- Are expensive operations (like full-text search) protected?
Example conceptual rate limit per IP:
POST /auth/login: 5 requests per minute per IP.POST /password-reset: 3 requests per hour per email.
You can implement rate limiting with Redis, storing counters per key:
| Key example | Meaning |
|---|---|
login:ip:203.0.113.5 | Login attempts from this IP |
password_reset:email:... | Password reset requests per email |
If a threshold is reached, return HTTP 429 Too Many Requests.
Incident Response Basics
You do not need a big company process, but you should think through:
- How will you detect a possible incident?
- What is your first step if you suspect compromise?
- Can you:
- Rotate tokens and secrets?
- Temporarily disable certain features or stop traffic?
- Restore from backups if needed?
Document a simple plan in your SECURITY_REVIEW.md, for example:
- Step 1: Disable new logins and sensitive actions.
- Step 2: Rotate JWT secret, force logout for all users.
- Step 3: Analyze logs around suspected time.
- Step 4: If data was modified or deleted, restore from backup.
A Minimal Pre‑Production Security Checklist
You can adapt this table for your final project and tick items explicitly.
| Area | Check | Done |
|---|---|---|
| Code | Debug mode and reload disabled in production | [ ] |
| Code | No stack traces or internal details in API responses | [ ] |
| Dependencies | Dependencies scanned for vulnerabilities and updated | [ ] |
| Auth | Passwords hashed with Argon2/bcrypt, never stored in plain text | [ ] |
| Auth | JWTs signed with strong secret, have expiration, verified on every request | [ ] |
| Authorization | All sensitive routes require appropriate roles / ownership checks | [ ] |
| Input validation | All request bodies validated with Pydantic, types and sizes checked | [ ] |
| Injection | All DB access uses ORM or parameterized queries | [ ] |
| XSS / CSRF | Templates escape user data, cookies have SameSite where needed | [ ] |
| File uploads | File types validated, size limited, stored safely | [ ] |
| Secrets | No secrets in repo, all come from env/secrets manager | [ ] |
| HTTPS | HTTPS enabled, HTTP redirected to HTTPS | [ ] |
| Network | DB and Redis not publicly accessible, firewall configured | [ ] |
| DB | App DB user uses least privilege, no SUPERUSER | [ ] |
| Backups | Automated DB backups configured and tested | [ ] |
| Logging | Sensitive data excluded from logs, important actions logged | [ ] |
| Monitoring | Basic monitoring and alerts set up (uptime, error rates, login failures) | [ ] |
| Rate limiting | Rate limiting implemented for login and other sensitive / expensive endpoints | [ ] |
As you finalize your production backend, go through each of these checks, adapt them to your architecture, and document what you have verified. This final security review step is what separates a hobby project from something you can confidently deploy to real users.
Views: 7
KAHIBARO