How do I secure a Django app?

Direct answer

Django's defaults are good, so most problems come from settings and from the ORM's escape hatches. Confirm `DEBUG` is false in production, `ALLOWED_HOSTS` is set, the secret key isn't in version control, and the `SECURE_*` settings are on. Then check every `get_object_or_404` filters by the current user, not just the ID.

Muhammad HasanUpdated

Django is opinionated about security in a way Express isn't. CSRF protection is on, the ORM parameterises, templates escape by default. That moves the problems elsewhere.

Settings

DEBUG = False
ALLOWED_HOSTS = ['yourapp.com']
SECRET_KEY = os.environ['SECRET_KEY']       # not in the repo

SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
X_FRAME_OPTIONS = 'DENY'

DEBUG = True in production is the serious one. Django's debug page includes settings, environment variables and query history, and it's shown to anyone who triggers an error.

A secret key committed to the repository means session cookies and password reset tokens can be forged. If it's been in version control, rotate it, removing the commit doesn't help.

Run python manage.py check --deploy before shipping. It checks most of this for you.

The ORM's escape hatches

Queries built through the ORM are parameterised. Three things aren't:

# Vulnerable
User.objects.raw(f"SELECT * FROM users WHERE email = '{email}'")

# Safe
User.objects.raw("SELECT * FROM users WHERE email = %s", [email])

extra() is deprecated and hard to use safely. Remove it where you find it. connection.cursor() gives you the raw driver, with the same rules as any DB-API code.

The one Django doesn't cover

# Vulnerable
def order_detail(request, order_id):
    order = get_object_or_404(Order, pk=order_id)
    return render(request, 'order.html', {'order': order})

@login_required confirms someone is logged in. Nothing confirms they own this order.

# Fixed
order = get_object_or_404(Order, pk=order_id, user=request.user)

Ownership belongs in the lookup. You'll find this pattern in Django codebases of every vintage, and it's the same bug that did the most damage in our 24-app study.

Django REST Framework has the same issue at a different layer, a ModelViewSet with a queryset covering every row will serve any object by ID unless you override get_queryset to filter by the requesting user.

Templates

Django escapes by default. |safe and mark_safe turn it off, so anywhere they're applied to user-supplied content is a potential XSS.

Uploads

FileField accepts what it's given. Validate content type and extension, cap the size, and store uploads outside the static directory or on object storage. Not somewhere the server might execute them.

Finding the ones you already shipped

manage.py check --deploy covers settings. bandit is a reasonable free static analyser for Python and catches the raw SQL cases.

Neither catches the missing ownership filter, because get_object_or_404(Order, pk=order_id) is legitimate code in plenty of contexts. Telling safe from unsafe means knowing what the view is for, and that isn't something a pattern can encode. It shows up in the numbers: across the 66 Python repositories in RealVuln, the rule-based scanners topped out under 0.19 recall.

Go deeper

Kolega for AppSec teams

Related answers

See what your own repository returns

Connect a repo and run a scan. No credit card, no pipeline changes.

Get started for free