Why Django apps still get exploited

Django provides CSRF protection, ORM query parameterization, password hashing, and clickjacking headers out of the box. When developers disable these protections to meet a deadline (CSRF exempt decorators, raw SQL for complex queries, DEBUG=True in production) or when they build Django REST Framework APIs with incomplete permission classes, the framework's defaults cannot save them. Our job is to find where your team deviated from the secure path.

1. Django ORM and raw query injection

Django's ORM parameterizes all standard queryset operations. The injection risk lies in RawSQL(), extra(), and string formatting inside filter() conditions. We grep through your codebase for every database interaction and manually test any raw query path with injection payloads:

# VULNERABLE raw query using string formatting
def get_user_transactions(request):
    user_id = request.GET.get('user_id')
    # Injection: user_id is inserted directly into the query string
    transactions = Transaction.objects.raw(
        f"SELECT * FROM transactions WHERE user_id = {user_id}"
    )
    return list(transactions)

Beyond injection, we look for mass assignment vulnerabilities in Django REST Framework serializers. If your serializer declares fields = '__all__' and your view calls serializer.save() without overriding perform_create or perform_update, client-submitted JSON can write to any column on the model including internal flags like is_staff or kyc_tier.

2. Django REST Framework permission class auditing

The most common critical finding on Django REST Framework APIs is an incomplete permission implementation. Django REST Framework separates list-level permissions (handled by has_permission()) from object-level permissions (handled by has_object_permission()). Many developers implement the list-level check and assume object-level authorization is handled, but has_object_permission() is only called if you explicitly call self.get_object() in the view. Direct ORM queries bypass it entirely:

# VULNERABLE view: bypasses has_object_permission
class WalletDetailView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request, wallet_id):
        # Fetches any wallet by ID, no ownership check performed
        wallet = Wallet.objects.get(id=wallet_id)
        return Response(WalletSerializer(wallet).data)

3. Django admin panel exposure and brute force

The Django admin panel is typically available at /admin/. We test three attack vectors: (1) enumeration of admin usernames via timing differences in the login response, (2) credential brute-force (Django admin does not throttle login attempts by default), and (3) admin panel access from compromised staff credentials in your staff password manager or Slack history. We recommend restricting the admin panel to VPN or internal IP ranges and changing the default URL prefix.

4. DEBUG mode and information disclosure

Django's DEBUG mode produces detailed error pages that include: the full stack trace, local variable values at each stack frame, the complete list of URL patterns, and all installed Django settings keys. We have found DEBUG=True in production environments in roughly one in five Django apps we audit. An attacker who triggers an unhandled exception receives a complete map of the application internals for free.

5. Session and CSRF configuration

We audit your session cookie flags (HttpOnly, Secure, SameSite), session expiry configurations, and the placement of @csrf_exempt decorators on views that modify data. We also test whether your ALLOWED_HOSTS setting is correctly locked down, since a permissive wildcard configuration allows HTTP Host header injection that can poison password reset links.

Real finding from a Django engagement

DRF serializer mass assignment to is_verified field

During a penetration test of a Nigerian KYC platform, we found a user update endpoint backed by a DRF ModelSerializer with fields = '__all__'. We submitted a PATCH request containing "is_verified": true, "kyc_tier": 3 alongside our legitimate profile update. The serializer accepted both fields and saved them to the database, upgrading our test account to fully verified KYC Tier 3 without submitting any identification documents. Fix priority: critical. Remediated by declaring an explicit field whitelist in the serializer and removing is_verified and kyc_tier from writable fields.

Running a Django application in production in Nigeria? Get a practitioner-led security assessment.

Book a Django Pentest

Frequently asked questions

Does Django's built-in ORM prevent SQL injection automatically?

The ORM parameterizes queries by default but does not protect you if developers use RawSQL, extra(), or annotate() with unsanitized user input. We specifically test every raw query and string formatting pattern in your codebase.

Is the Django admin panel dangerous to expose?

Yes. The Django admin interface at /admin/ is a full database management interface. Even with authentication, a single compromised admin account gives read and write access to every model in the database. Exposing it on the public internet dramatically increases the attack surface.

What Django REST Framework permission classes do you test for bypass?

We test IsAuthenticated, IsAdminUser, custom BasePermission subclasses, and object-level permissions defined in has_object_permission(). The most common bypass finding is missing has_object_permission() implementations that allow any authenticated user to read or modify any object.

How long does a Django penetration test take?

A focused Django API security assessment for a production application typically takes 5 to 8 business days. Django monolithic applications with an admin panel, REST API, and front-end take 8 to 12 days for comprehensive coverage.

Related reading

Blog: Securing Django REST APIs · Flask penetration testing · API data leaks

Services: Penetration testing · API security testing