REST API Security: The 10 Most Common Errors and How to Avoid Them

In the previous article we built a complete REST API with FastAPI and Vue 3. Now we're going to try to break it.

This article was born from a process I apply in all my projects — including DevGuardian AI, my vulnerability detection platform: once the backend is up and running, I analyze it as if I were an attacker. What endpoints are exposed. What data leaks. What happens if someone manipulates the tokens or brute forces IDs.

What I found changed the way I write code.

Most REST API vulnerabilities aren't sophisticated exploits. They're design mistakes that any developer makes when prioritizing "getting it to work" over "making it secure." This article covers the ten most common ones — with vulnerable code examples, the attack that exploits them, and the fix.

Base reference: OWASP API Security Top 10 — the industry standard for API vulnerabilities.

⚠️ Warning: All attack code in this article is for controlled environments and educational purposes. Applying these techniques to systems without explicit authorization is illegal.


1. Broken Object Level Authorization (BOLA) — The Most Dangerous

OWASP API1:2023

BOLA is consistently #1 in the OWASP ranking — and for good reason. It occurs when the API exposes endpoints that operate on resources identified by ID without verifying that the authenticated user has permission over that specific resource.

The Vulnerable Code

# ❌ VULNERABLE — FastAPI
@router.get("/tasks/{task_id}")
def get_task(
    task_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    # Busca la tarea por ID sin verificar que pertenece al usuario
    task = db.query(Task).filter(Task.id == task_id).first()
    if not task:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
    return task

The Attack

# Usuario A está autenticado y sabe que su tarea tiene el ID 42
# Simplemente itera IDs para ver tareas de otros usuarios:

for i in {1..1000}; do
  curl -s -H "Authorization: Bearer TOKEN_USUARIO_A" \
    https://api.ejemplo.com/api/v1/tasks/$i | jq '.title, .owner_id'
done

The attacker can read — and potentially modify or delete — any user's data with a simple loop. No stolen credentials or exploits needed. Just change a number in the URL.

The Fix

# ✅ CORRECTO — siempre filtrar por owner_id además del ID del recurso
@router.get("/tasks/{task_id}")
def get_task(
    task_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    task = db.query(Task).filter(
        Task.id == task_id,
        Task.owner_id == current_user.id  # ← esta línea es la diferencia
    ).first()
    if not task:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Task not found",
        )
    return task

Rule: On every endpoint that operates on a specific resource, the filter must always include the authenticated user's identifier. Never trust the URL ID as a guarantee of authorization.


2. Broken Authentication — Poorly Implemented JWT

OWASP API2:2023

JWT is the most widely used authentication mechanism in modern APIs — and also one of the most poorly implemented. The most common errors aren't in the algorithm, but in the validation.

Error 1: Accepting the none algorithm

# ❌ VULNERABLE — no especificar algoritmos permitidos
payload = jwt.decode(token, SECRET_KEY)

# Un atacante puede forjar un token así:
# Header: {"alg": "none", "typ": "JWT"}
# Payload: {"sub": "1", "type": "access"}
# Signature: (vacía)
# El token pasa la validación si no especificas algoritmos
# ✅ CORRECTO — siempre especificar la lista de algoritmos permitidos
payload = jwt.decode(
    token,
    SECRET_KEY,
    algorithms=["HS256"]  # lista explícita, nunca omitir
)

Error 2: Not verifying the token type

# ❌ VULNERABLE — usar el refresh token como access token
def decode_token(token: str) -> TokenData:
    payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
    return TokenData(user_id=payload.get("sub"))
    # No verifica si es access o refresh — ambos son intercambiables

# Un atacante con un refresh token expirado puede intentar usarlo
# como access token si no verificas el campo "type"
# ✅ CORRECTO — validar el claim "type" explícitamente
def decode_access_token(token: str) -> TokenData:
    payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

    if payload.get("type") != "access":
        raise ValueError("Token type inválido — se esperaba access token")

    user_id = payload.get("sub")
    if not user_id:
        raise ValueError("Token sin subject")

    return TokenData(user_id=int(user_id))

Error 3: Weak or hardcoded secret key

# ❌ VULNERABLE
SECRET_KEY = "secret"           # trivialmente bruteforceable
SECRET_KEY = "mysupersecret123" # en texto plano en el código

# ✅ CORRECTO — generar con entropía suficiente y leer desde env
# Generar: openssl rand -hex 32
SECRET_KEY = os.environ.get("SECRET_KEY")  # mínimo 32 bytes aleatorios
if not SECRET_KEY:
    raise RuntimeError("SECRET_KEY no configurada")

3. Broken Object Property Level Authorization — Mass Assignment

OWASP API3:2023

It occurs when the client can modify object properties that they shouldn't control — such as their own role, verification status, or balance.

The Vulnerable Code

# ❌ VULNERABLE — Laravel ejemplo
public function update(Request $request, User $user)
{
    // Acepta cualquier campo que el cliente envíe
    $user->update($request->all());
    return response()->json($user);
}
# El atacante envía campos que no debería poder modificar:
curl -X PUT https://api.ejemplo.com/api/v1/users/me \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Usuario Normal",
    "role": "admin",
    "is_verified": true,
    "credits": 99999
  }'

The Fix

# ✅ CORRECTO — FastAPI con schema estricto
class UserUpdateSchema(BaseModel):
    # Solo los campos que el usuario puede modificar
    name: str | None = Field(default=None, max_length=100)
    bio: str | None = Field(default=None, max_length=500)
    # Campos como 'role', 'is_verified', 'credits' NO están aquí

@router.put("/users/me", response_model=UserResponse)
def update_profile(
    data: UserUpdateSchema,  # Pydantic valida y filtra automáticamente
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    for field, value in data.model_dump(exclude_unset=True).items():
        setattr(current_user, field, value)
    db.flush()
    return current_user
// ✅ CORRECTO — Laravel con fillable explícito
class User extends Model
{
    // Solo estos campos son asignables masivamente
    protected $fillable = ['name', 'bio'];

    // Nunca en $fillable: role, is_admin, is_verified, balance
}

public function update(Request $request)
{
    $validated = $request->validate([
        'name' => 'sometimes|string|max:100',
        'bio'  => 'sometimes|string|max:500',
    ]);
    auth()->user()->update($validated);
}

4. Unrestricted Resource Consumption — Without Rate Limiting

OWASP API4:2023

Without rate limiting, your API is vulnerable to login brute force, user enumeration, costly endpoint spam, and basic denial of service attacks.

The Attack

# Fuerza bruta en el endpoint de login sin ningún límite:
while true; do
  curl -s -X POST https://api.ejemplo.com/api/v1/auth/login \
    -d "username=victima@email.com&password=$(shuf -n1 wordlist.txt)"
done
# Con una wordlist de 10,000 passwords comunes y sin rate limit,
# esto tarda minutos en ejecutarse

The Fix in FastAPI with slowapi

# requirements: pip install slowapi
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from fastapi import Request

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

# Aplicar límites específicos por endpoint
@router.post("/auth/login")
@limiter.limit("5/minute")          # máximo 5 intentos por minuto por IP
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
    ...

@router.post("/auth/register")
@limiter.limit("3/hour")            # registros limitados por hora
async def register(request: Request, user_data: UserCreate):
    ...

@router.get("/tasks")
@limiter.limit("60/minute")         # endpoints normales más permisivos
async def get_tasks(request: Request):
    ...

Rate limiting in Laravel with middleware

// routes/api.php
Route::middleware(['throttle:5,1'])->group(function () {
    // 5 requests por minuto
    Route::post('/auth/login', [AuthController::class, 'login']);
});

Route::middleware(['throttle:60,1'])->group(function () {
    // 60 requests por minuto para endpoints normales
    Route::apiResource('tasks', TaskController::class);
});

Beyond IP-based rate limiting, consider:

  • Rate limiting by authenticated user (more precise than by IP)
  • CAPTCHA or Turnstile on registration and password recovery endpoints
  • Alerts when a user exceeds unusual request thresholds

  • 5. Broken Function Level Authorization — Exposed Admin Endpoints

    OWASP API5:2023

    The API exposes administration endpoints without properly verifying that the user has the necessary role — or worse, trusts that the client won't "discover" the routes.

    The Vulnerable Code

    # ❌ VULNERABLE — verificación de rol inconsistente
    @router.delete("/admin/users/{user_id}")
    def delete_user(
        user_id: int,
        current_user: User = Depends(get_current_user),
        db: Session = Depends(get_db),
    ):
        # Verifica autenticación pero no el rol
        user = db.query(User).filter(User.id == user_id).first()
        db.delete(user)
        # Cualquier usuario autenticado puede eliminar cualquier cuenta

    The Attack

    # El atacante descubre rutas admin con fuzzing:
    ffuf -u https://api.ejemplo.com/api/v1/FUZZ \
         -w /usr/share/wordlists/api-endpoints.txt \
         -H "Authorization: Bearer TOKEN_USUARIO_NORMAL" \
         -mc 200,201,204
    
    # Resultado: /admin/users, /admin/stats, /admin/config
    # Todos accesibles con un token de usuario regular

    The Fix

    # ✅ CORRECTO — dependency específica para admin
    from fastapi import HTTPException, status
    
    def require_admin(current_user: User = Depends(get_current_user)) -> User:
        if current_user.role != "admin":
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Insufficient permissions",
            )
        return current_user
    
    # Aplicar en todos los endpoints admin
    @router.delete("/admin/users/{user_id}")
    def delete_user(
        user_id: int,
        admin: User = Depends(require_admin),  # ← falla si no es admin
        db: Session = Depends(get_db),
    ):
        ...

    Key principle: Security through obscurity doesn't exist. Assuming nobody will "discover" /admin/ is naive. Roles must be verified server-side on every request, not on the client.


    6. Unrestricted Access to Sensitive Business Flows

    OWASP API6:2023

    Some business flows are costly or sensitive by nature — creating accounts, applying coupons, generating reports, processing payments. Without additional protections, they can be automated and abused.

    Real-world Abuse Examples

    # Abuso de sistema de referidos:
    # Si crear una cuenta genera créditos para el referidor,
    # un script puede crear miles de cuentas falsas:
    for i in {1..1000}; do
      curl -X POST https://api.ejemplo.com/api/v1/auth/register \
        -d "{\"email\": \"fake$i@tempmail.com\", \"referral_code\": \"VICTIMA123\"}"
    done
    
    # Abuso de cupones de descuento de un solo uso:
    # Sin validación atómica, race conditions permiten aplicar
    # el mismo cupón múltiples veces en requests simultáneos

    The Fix

    # ✅ Validación atómica con bloqueo en base de datos
    # Para cupones de un solo uso — usando SELECT FOR UPDATE
    @router.post("/checkout/apply-coupon")
    def apply_coupon(
        coupon_code: str,
        current_user: User = Depends(get_current_user),
        db: Session = Depends(get_db),
    ):
        # FOR UPDATE previene race conditions — bloquea el registro
        coupon = (
            db.query(Coupon)
            .filter(Coupon.code == coupon_code, Coupon.is_used == False)
            .with_for_update()  # ← bloqueo a nivel de fila
            .first()
        )
        if not coupon:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="Coupon invalid or already used",
            )
        coupon.is_used = True
        coupon.used_by = current_user.id
        # get_db() hace el commit al cerrar — atómico
        return {"discount": coupon.discount_percent}

    7. Server Side Request Forgery (SSRF)

    OWASP API7:2023

    It occurs when the API accepts user-controlled URLs and makes requests from the server — allowing the attacker to probe the internal network, access cloud metadata, or perform pivoting.

    The Vulnerable Code

    # ❌ VULNERABLE — acepta URLs sin validación
    @router.post("/import/url")
    async def import_from_url(url: str, current_user: User = Depends(get_current_user)):
        import httpx
        response = await httpx.get(url)  # El servidor hace el request
        return process_data(response.content)
    # El atacante apunta a la metadata de AWS EC2:
    curl -X POST https://api.ejemplo.com/api/v1/import/url \
      -d '{"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}'
    
    # O a servicios internos no expuestos:
    curl -X POST https://api.ejemplo.com/api/v1/import/url \
      -d '{"url": "http://interno.empresa.local/admin/config"}'
    
    # O a Redis si está en la red interna sin autenticación:
    curl -X POST https://api.ejemplo.com/api/v1/import/url \
      -d '{"url": "http://redis:6379/"}'

    The Fix

    # ✅ CORRECTO — validar y restringir URLs permitidas
    import ipaddress
    from urllib.parse import urlparse
    
    ALLOWED_SCHEMES = {"https"}
    BLOCKED_HOSTS = {
        "localhost", "127.0.0.1", "0.0.0.0",
        "169.254.169.254",  # AWS metadata
        "metadata.google.internal",  # GCP metadata
    }
    
    def validate_url(url: str) -> str:
        parsed = urlparse(url)
    
        if parsed.scheme not in ALLOWED_SCHEMES:
            raise ValueError(f"Scheme no permitido: {parsed.scheme}")
    
        hostname = parsed.hostname or ""
    
        if hostname in BLOCKED_HOSTS:
            raise ValueError("Host bloqueado")
    
        # Bloquear IPs privadas y de loopback
        try:
            ip = ipaddress.ip_address(hostname)
            if ip.is_private or ip.is_loopback or ip.is_link_local:
                raise ValueError("IP privada no permitida")
        except ValueError as e:
            if "not a valid" not in str(e):
                raise
    
        return url
    
    @router.post("/import/url")
    async def import_from_url(
        url: str,
        current_user: User = Depends(get_current_user),
    ):
        try:
            safe_url = validate_url(url)
        except ValueError as e:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail=str(e),
            )
        async with httpx.AsyncClient(
            follow_redirects=False,  # no seguir redirects — pueden bypassear la validación
            timeout=5.0,
        ) as client:
            response = await client.get(safe_url)
        return process_data(response.content)

    8. Security Misconfiguration — Poorly Configured CORS

    OWASP API8:2023

    Poorly configured CORS is one of the most common and easiest mistakes to make. A wildcard or lax validation can allow any website to make authenticated requests to your API from a user's browser.

    Dangerous Configurations

    # ❌ VULNERABLE — wildcard con credenciales (inválido pero intentado)
    app.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_credentials=True,  # Los browsers bloquean esto, pero el intento es un error de diseño
    )
    
    # ❌ VULNERABLE — validación de origin con startswith (bypasseable)
    def is_allowed_origin(origin: str) -> bool:
        return origin.startswith("https://miapp.com")
        # "https://miapp.com.attacker.com" pasa esta validación

    The Fix

    # ✅ CORRECTO — lista blanca explícita y estricta
    import os
    
    ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "").split(",")
    # En producción: ALLOWED_ORIGINS=https://miapp.com,https://www.miapp.com
    
    app.add_middleware(
        CORSMiddleware,
        allow_origins=ALLOWED_ORIGINS,      # lista explícita, nunca "*"
        allow_credentials=True,
        allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
        allow_headers=["Authorization", "Content-Type"],
        max_age=600,                         # cachear preflight 10 minutos
    )

    Other common misconfigurations:

    # ❌ Exponer stack trace en producción
    app = FastAPI(debug=True)  # Nunca en producción
    
    # ❌ Headers de servidor que revelan tecnología
    # Por defecto FastAPI incluye: server: uvicorn
    # Nginx debería sobreescribir esto:
    # server_tokens off;
    
    # ❌ Endpoints de documentación expuestos en producción
    app = FastAPI(
        docs_url=None,    # ✅ Deshabilitar en producción
        redoc_url=None,
        openapi_url=None,
    )

    9. Improper Inventory Management — Old Versions Exposed

    OWASP API9:2023

    APIs accumulate versions. The problem is that /api/v1/ gets "retired" but keeps working — with 2021's security, without 2024's fixes.

    The Problem

    # La empresa lanzó v2 con autenticación mejorada.
    # v1 sigue activa "por compatibilidad con clientes viejos":
    
    curl https://api.ejemplo.com/api/v1/users/me  # sin auth → funciona
    curl https://api.ejemplo.com/api/v2/users/me  # sin auth → 401
    
    # El atacante simplemente usa v1. Toda la seguridad de v2 es irrelevante.
    
    # También común: endpoints de desarrollo o testing en producción:
    curl https://api.ejemplo.com/api/v1/debug/users  # lista todos los usuarios
    curl https://api.ejemplo.com/api/v1/health/db    # expone info de la DB

    The Fix

    # ✅ Deprecación activa con headers de aviso
    from fastapi import Response
    from datetime import datetime, timezone
    
    DEPRECATED_AFTER = datetime(2026, 12, 31, tzinfo=timezone.utc)
    
    def deprecation_warning(response: Response):
        """Dependency que añade headers de deprecación."""
        response.headers["Deprecation"] = DEPRECATED_AFTER.isoformat()
        response.headers["Sunset"] = DEPRECATED_AFTER.strftime("%a, %d %b %Y %H:%M:%S GMT")
        response.headers["Link"] = '</api/v2>; rel="successor-version"'
    
    # Aplicar a todos los endpoints de v1
    v1_router = APIRouter(
        prefix="/api/v1",
        dependencies=[Depends(deprecation_warning)],
    )
    # Checklist de inventario de API — ejecutar regularmente:
    
    # 1. Listar todos los endpoints activos
    curl https://api.ejemplo.com/api/openapi.json | jq '.paths | keys[]'
    
    # 2. Verificar que endpoints de debug no estén en producción
    grep -r "debug\|test\|dev\|internal" routes/
    
    # 3. Documentar qué versiones están activas y cuándo se retiran

    10. Unsafe Consumption of APIs — Blindly Trusting External APIs

    OWASP API10:2023

    Your API likely consumes other APIs — payments, maps, social auth, data providers. If you blindly trust those responses without validation, a compromised provider can inject malicious data into your system.

    The Vulnerable Code

    # ❌ VULNERABLE — confiar sin validar en respuesta de API externa
    @router.post("/auth/google")
    async def google_oauth(token: str):
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"https://oauth2.googleapis.com/tokeninfo?id_token={token}"
            )
        user_data = response.json()
        # Usa directamente email y name sin validar
        user = get_or_create_user(email=user_data["email"], name=user_data["name"])
        return create_session(user)

    The Fix

    # ✅ CORRECTO — validar estructura y contenido de respuestas externas
    from pydantic import BaseModel, EmailStr
    
    class GoogleTokenInfo(BaseModel):
        """Schema estricto para la respuesta de Google."""
        email: EmailStr
        email_verified: bool
        name: str
        sub: str           # Google user ID
        aud: str           # debe coincidir con tu CLIENT_ID
    
        @field_validator("email_verified")
        @classmethod
        def email_must_be_verified(cls, v: bool) -> bool:
            if not v:
                raise ValueError("Email no verificado por Google")
            return v
    
    GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
    
    @router.post("/auth/google")
    async def google_oauth(token: str):
        async with httpx.AsyncClient(timeout=5.0) as client:
            response = await client.get(
                "https://oauth2.googleapis.com/tokeninfo",
                params={"id_token": token},
            )
        if response.status_code != 200:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Token de Google inválido",
            )
    
        try:
            token_info = GoogleTokenInfo.model_validate(response.json())
        except ValidationError:
            raise HTTPException(
                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
                detail="Respuesta de Google con formato inesperado",
            )
    
        # Verificar que el token fue emitido para NUESTRA aplicación
        if token_info.aud != GOOGLE_CLIENT_ID:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Token no emitido para esta aplicación",
            )
    
        user = get_or_create_user(email=token_info.email, name=token_info.name)
        return create_session(user)

    Security Checklist for REST APIs

    Before deploying any API to production, this is the minimum checklist:

    ### Authentication and Authorization
    - [ ] All protected endpoints require a valid token
    - [ ] Each resource verifies it belongs to the authenticated user (anti-BOLA)
    - [ ] Admin endpoints have a separate role dependency
    - [ ] JWT validates algorithm, token type, and expiration
    - [ ] SECRET_KEY has at least 32 bytes of random entropy
    
    ### Inputs and Validation
    - [ ] All inputs go through Pydantic schemas / Laravel Form Requests
    - [ ] No `request->all()` or `**request.dict()` without explicit filtering
    - [ ] External URLs are validated against a private host/IP blocklist
    - [ ] External API responses are validated with strict schemas
    
    ### Rate Limiting and Availability
    - [ ] Auth endpoints have strict rate limiting (≤5/min per IP)
    - [ ] Costly endpoints have additional limits
    - [ ] Timeouts configured on all external HTTP clients
    
    ### Configuration
    - [ ] CORS with explicit allowlist, never "*" with credentials
    - [ ] Debug mode disabled in production
    - [ ] Docs (Swagger/ReDoc) disabled in production or protected
    - [ ] Server headers don't reveal versions (server_tokens off)
    - [ ] Sensitive variables in env vars, never hardcoded
    
    ### Inventory
    - [ ] Old API versions have a defined sunset date
    - [ ] Debug/test endpoints don't exist in production
    - [ ] An up-to-date registry of all public endpoints exists

    For automated analysis before each release:

    Static Analysis:

  • Bandit — security analysis for Python, detects hardcoded secrets, unsafe use of cryptographic functions, and more
  • Semgrep — security rules for Python, PHP, JavaScript and more. Free for open source projects
  • # Bandit — escanear el directorio del proyecto
    pip install bandit
    bandit -r ./app -ll  # -ll solo reporta medium y high severity
    
    # Semgrep — con reglas de OWASP
    semgrep --config=p/owasp-top-ten ./app

    Dynamic Analysis (black-box):

  • OWASP ZAP — interception proxy to analyze requests/responses
  • Nuclei — scanner with templates for known API vulnerabilities
  • # Nuclei contra tu API local
    nuclei -u http://localhost:8000 -t nuclei-templates/http/

    For CI/CD — GitHub Actions:

    # .github/workflows/security.yml
    name: Security Scan
    
    on: [push, pull_request]
    
    jobs:
      bandit:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-python@v5
            with:
              python-version: '3.12'
          - run: pip install bandit
          - run: bandit -r ./app -ll --exit-zero -f json -o bandit-report.json
          - uses: actions/upload-artifact@v4
            with:
              name: bandit-report
              path: bandit-report.json

    Conclusion

    API security isn't a feature you add at the end — it's a design property that's built from the start. The ten errors in this article don't require advanced cryptography or operating system knowledge. They require discipline: reviewing every endpoint with the question "what happens if someone tries to abuse this?"

    The process I follow on DevGuardian AI is simple: after implementing any new endpoint, I treat it as an attacker for 10 minutes. Can I access other users' data? Can I bypass authorization? Can I make the server do something it shouldn't?

    That mental exercise has found more vulnerabilities than any automated tool.

    The checklist above is the starting point. It's not exhaustive — security never is — but it covers 80% of what attackers try first.


    Resources

  • OWASP API Security Top 10: owasp.org/www-project-api-security
  • Bandit (Python): bandit.readthedocs.io
  • Semgrep: semgrep.dev
  • OWASP ZAP: zaproxy.org
  • Nuclei: nuclei.projectdiscovery.io
  • PortSwigger Web Security Academy: portswigger.net/web-security — free labs to practice each vulnerability

  • Did you find a vulnerability in any example of this article or have something to add? Technical discussion is welcome — you can find me on GitHub or through my portfolio.