Django Security Best Practices: Protecting Your Web Application
A secure framework won't produce a secure app on its own. Django gives you the tools, but using them right is your job. Here are the checks that catch the misconfigurations behind most breaches.
Technically reviewed by:
Lucio Ricardo M.|Remy O.|Syed Ali H.
Table of contents
Key Takeaways
- Django is secure by default against CSRF, XSS, and SQL injection, but defaults protect nothing once you misconfigure or override them.
- Most breaches start with small oversights like DEBUG=True, an exposed SECRET_KEY, or ALLOWED_HOSTS=['*'], not sophisticated zero-days.
- The single highest-impact production setting is DEBUG = False; leaving it on leaks stack traces, settings, and source.
- Never undo the framework's defenses: no |safe or mark_safe on user input, no string-formatted raw SQL, no global CSRF exemption.
- Layer your controls (defense in depth): auto-escaping plus CSP, ORM parameterization, object-level permission checks, rate limiting, and secure cookies over HTTPS.
- Make python manage.py check --deploy a deployment gate, and scan dependencies with pip-audit on every build.
Django has earned its reputation as one of the most secure web frameworks available. Out of the box, it defends against the most common web vulnerabilities, which is a real advantage over building from scratch. The catch is that a secure framework does not, on its own, produce a secure application. Django hands you the tools; using them correctly is still your job.
That gap is where real money is lost. The IBM Cost of a Data Breach Report put the 2025 global average breach at $4.44 million and the US average at a record $10.22 million. The overwhelming majority of those incidents began with the kind of small oversight covered below rather than a sophisticated zero-day.
Over the past seven years, I have run security reviews on dozens of Django applications. Some were well-protected, while others had serious vulnerabilities that could have been avoided with basic knowledge. The pattern is consistent; most security flaws result from minor misconfigurations, not exotic attack vectors.
This guide covers the checks I run on every project. Follow them, and you will be ahead of the vast majority of Django applications in production.
Common Django Security Vulnerabilities
Before getting to solutions, it helps to name what you are defending against. The most common vulnerabilities in web applications, Django included, fall into a handful of categories.
Cross-Site Request Forgery (CSRF) tricks a logged-in user into submitting a request they never intended to make. Cross-Site Scripting (XSS) injects malicious JavaScript that runs in other users' browsers. SQL Injection manipulates database queries by smuggling SQL code through user input. Broken Authentication covers weak passwords, missing two-factor authentication, and session hijacking. Security Misconfiguration is the broadest and most common category: debug mode left on in production, exposed secret keys, and missing security headers.
The OWASP Top 10 is the standard reference for these threats and worth bookmarking. The encouraging news is that Django addresses every one of them, provided the defenses are layered correctly. That layering, defense in depth, is the principle behind everything that follows: each control covers a different stage of the request.
No single layer is sufficient on its own. The sections below walk through each one in the order a request encounters it.
CSRF Protection: How Django Handles It
Django ships with CSRF protection enabled by default. It works by generating a unique token for each user session and requiring that token on every POST request, so a forged request from another site cannot supply a valid token.
In your templates, you include the token like this:
No single layer is sufficient on its own. The sections below walk through each one in the order a request encounters it.
CSRF Protection: How Django Handles It
Django ships with CSRF protection enabled by default. It works by generating a unique token for each user session and requiring that token on every POST request, so a forged request from another site cannot supply a valid token.
In your templates, you include the token like this:
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Submit</button>
</form>The mechanism is a simple handshake: the server issues a token with the page, the browser returns it with the submission, and Django rejects anything that does not match.
For AJAX requests, the token has to be sent in the request headers rather than in a form field. Here is how to do that with JavaScript:
// Get the CSRF token from the cookie function getCookie(name) { let cookieValue = null; if (document.cookie && document.cookie !== '') { const cookies = document.cookie.split(';'); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.substring(0, name.length + 1) === (name + '=')) { cookieValue = decodeURIComponent( cookie.substring(name.length + 1) ); break; } } } return cookieValue; }
// Include it in fetch requests fetch('/api/data/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken'), }, body: JSON.stringify(data), });
This is also the seam where backend and frontend security meet. Single-page apps that talk to a Django backend have to handle the token correctly on the client side, which is a detail that experienced React developers build into their API layer rather than bolt on later.
Never disable CSRF protection globally. If you genuinely need to exempt a single view, such as a third-party webhook endpoint, apply the @csrf_exempt decorator to that view alone and make sure a separate authentication mechanism protects it.
XSS Prevention: Escaping User Input
Django's template engine automatically escapes HTML in variables by default. If a user enters <script>alert('hacked')</script> into a form field, Django renders it as plain text rather than executable JavaScript. That default is silently doing a large amount of work for you.
The risk is in the places where you can accidentally switch escaping off:
<!-- SAFE - Django auto-escapes this --> <p>{{ user_comment }}</p><!-- DANGEROUS - |safe disables escaping --> <p>{{ user_comment|safe }}</p>
<!-- DANGEROUS - mark_safe in Python does the same --> from django.utils.safestring import mark_safe
Never use mark_safe on user-provided content
The decision comes down to whether you have bypassed escaping and, if so, whether you sanitized the input first.
Rules to follow:
The rules that keep you on the safe path are short.
- Never use the |safe filter on user-provided content.
- Never use mark_safe() on anything that includes user input.
- If you need to allow some HTML, such as output from a rich text editor, sanitize it with a library like bleach rather than trusting it:
# Using bleach to sanitize HTMLimport bleach
allowed_tags = ['p', 'b', 'i', 'u', 'a', 'br'] allowed_attrs = {'a': ['href', 'title']}
clean_html = bleach.clean( user_input, tags=allowed_tags, attributes=allowed_attrs, strip=True )
The bleach library lets you define an allowlist of tags and attributes and strips everything else, which is the correct posture: permit a known-safe set rather than trying to block a list of dangerous ones.
SQL Injection: Using Django ORM Safely
Django's ORM protects you from SQL injection by default because it uses parameterized queries. When you write a normal query, user input never touches the SQL string directly:
# SAFE - Django parameterizes this automatically Book.objects.filter(title=user_input)SAFE - even with lookups
Book.objects.filter(title__icontains=user_input)
Django sends user_input to the database as a parameter, and the database treats it as data rather than executable SQL. That separation is the entire defense.
But you can still reintroduce the vulnerability if you drop down to raw SQL carelessly:
# DANGEROUS - string formatting in raw SQL Book.objects.raw(f"SELECT * FROM books WHERE title = '{user_input}'")SAFE - use parameterized queries
Book.objects.raw("SELECT * FROM books WHERE title = %s", [user_input])
ALSO SAFE - using extra() with params
Book.objects.extra(where=["title = %s"], params=[user_input])
Rule: Always use parameterized queries when writing raw SQL, and never use string formatting or concatenation with user input in a query. The performance-tuning work that pushes teams toward raw SQL is also where this risk concentrates, which is one reason query optimization is best handled by back-end developers who treat parameterization as non-negotiable.
Authentication Best Practices
Password Hashing
Django uses PBKDF2 with SHA256 by default, which is strong. For applications that warrant it, you can upgrade to Argon2, the winner of the Password Hashing Competition, for an even stronger default:
# Install argon2 pip install django[argon2]config/settings.py
PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', ]
Order matters here. Django hashes new passwords with the first hasher in the list and transparently upgrades older hashes the next time each user logs in, so you migrate the whole user base without forcing a reset. Password Validation Django includes built-in password validators. Confirm they are configured rather than assuming the defaults are active:
config/settings.py
AUTH_PASSWORD_VALIDATORS = [ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 10}}, {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, ]
Two-Factor Authentication
For any application handling sensitive data, add two-factor authentication. The django-two-factor-auth and django-otp packages are the most widely used options and integrate cleanly with Django's auth system.
Authorization: Permissions and Decorators
Always check permissions explicitly. The fact that a URL is not linked anywhere does not mean users will not find it, so obscurity is never a substitute for an authorization check.
# Function-based views from django.contrib.auth.decorators import login_required, permission_required@login_required def dashboard(request): return render(request, 'dashboard.html')
@permission_required('books.can_publish', raise_exception=True) def publish_book(request, book_id): # Only users with the 'can_publish' permission can access this pass
Class-based views
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
class BookUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView): model = Book permission_required = 'books.change_book'
Important: The detail most often missed is object-level permission. A user who can edit books should not automatically be able to edit every book. Always confirm that the specific object belongs to, or is shared with, the user making the request, not just that the user holds the general permission.
HTTPS and Secure Cookies
In production, HTTPS is mandatory, and a few settings enforce it and harden your cookies:
# config/settings.py (PRODUCTION ONLY)
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = TrueWarning: Do not enable SECURE_SSL_REDIRECT in development, since it will break a local server running over HTTP. The clean way to manage this is environment variables that toggle these settings between development and production, which keeps a single settings file safe across both environments.
Rate Limiting and Throttling
Without rate limiting, an attacker can brute-force login forms, scrape your data, or simply overload your server. The django-ratelimit package adds it with a single decorator per view:
pip install django-ratelimitfrom django_ratelimit.decorators import ratelimit
@ratelimit(key='ip', rate='5/m', method='POST', block=True) def login_view(request): # Max 5 login attempts per minute per IP pass
@ratelimit(key='user', rate='100/h', method='GET', block=True) def api_view(request): # Max 100 API calls per hour per user pass
Note the two different keys. Limiting login attempts by IP slows brute-force attacks, while limiting API calls per authenticated user protects shared endpoints from abuse by any single account. Choosing the right key for each endpoint is what makes throttling effective rather than merely present.
Security Headers
Security headers tell browsers how to handle your site's content, and Django can set most of them for you:
# config/settings.pyPrevent clickjacking
X_FRAME_OPTIONS = 'DENY'
Content Security Policy (use django-csp)
pip install django-csp
MIDDLEWARE = [ # ... other middleware 'csp.middleware.CSPMiddleware', ] CSP_DEFAULT_SRC = ("'self'",) CSP_SCRIPT_SRC = ("'self'",) CSP_STYLE_SRC = ("'self'", "'unsafe-inline'") CSP_IMG_SRC = ("'self'", 'data:')
Other security settings
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_BROWSER_XSS_FILTER = True
A Content Security Policy is one of the most effective defenses against XSS because it restricts where scripts are allowed to load from, which neutralizes most injected payloads even if one slips through escaping. The django-csp package makes the policy straightforward to configure and tighten over time.
Security Checklist Before Deployment
Django includes a built-in security checker. Run it before every deployment:
python manage.py check --deployThis command inspects your configuration for common security misconfigurations and tells you exactly what to fix. It should be a gate in your deployment process, not an occasional manual step.
Beyond the automated check, here is the checklist I run through on every project:
- DEBUG = False in production. Leaving it on exposes stack traces, settings, and source code.
- SECRET_KEY is unique, random, and never committed to version control.
- ALLOWED_HOSTS is set to your actual domain names, not ['*'].
- Database credentials are stored in environment variables, not in settings.py.
- HTTPS is enforced with SSL redirect and secure cookies.
- Security headers are configured (HSTS, X-Frame-Options, CSP).
- Rate limiting is enabled on login and other sensitive endpoints.
- File uploads are validated for type and size and stored outside the web root.
- The Admin URL has been changed from the default /admin/ to something less obvious.
- Unused middleware and apps are removed to shrink the attack surface.
- Dependencies are kept up to date, with pip-audit or safety run regularly.
Security Audit Tools
A short toolchain covers most of what you need to verify the work above. Django's built-in check (python manage.py check --deploy) catches configuration issues. pip-audit scans your Python dependencies for known vulnerabilities. Bandit performs static analysis to flag insecure patterns in your own code. And the Mozilla Observatory tests your live site's HTTP headers and grades the result, which is a useful external check on the headers you configured earlier.
Frequently Asked Questions
Is Django secure by default?
Django is secure by default for the most common web vulnerabilities. It enables CSRF protection, auto-escapes template output to prevent XSS, and parameterizes ORM queries to block SQL injection without any extra configuration. What it cannot do is protect against misconfiguration. Leaving DEBUG on, exposing the SECRET_KEY, or disabling its defenses will undo that protection, so "secure by default" is a strong starting point rather than a finished state.
How do I protect a Django application against SQL injection?
Use the ORM for queries wherever possible, since it automatically parameterizes inputs and treats user data as values rather than as executable SQL. When you must write raw SQL, always pass user input through the parameter argument (%s placeholders with a params list), never through string formatting or concatenation. That single discipline closes the SQL injection path almost entirely.
What is the single most important Django security setting in production?
Setting DEBUG = False is the highest-impact change. With DEBUG enabled, an error page can expose stack traces, environment settings, and source code snippets to anyone who triggers an exception. Running python manage.py check --deploy will flag this along with the other settings that most often cause production incidents.
How often should I audit Django dependencies for vulnerabilities?
Run a dependency scan such as pip-audit or safety on every build, and ideally as part of your CI pipeline so a newly disclosed vulnerability surfaces immediately. Dependencies are a frequent breach vector precisely because they are easy to forget, so automating the check is more reliable than scheduling a periodic manual review.
Need a Security-Focused Django Team?
A breach is one of the most expensive failures a business can absorb, and remediation after the fact costs far more than building securely from the start. The practices above prevent common cases, but mature security also depends on threat modeling, code review, and infrastructure hardening, all of which benefit from dedicated expertise.
Softaims builds security into every engagement from day one rather than treating it as an afterthought. Hire Django developers who treat secure defaults as the baseline, or bring in Python developers and full-stack developers to harden an application end to end, from the database to the browser.
Sergei A.
My name is Sergei A. and I have over 10 years of experience in the tech industry. I specialize in the following technologies: FastAPI, Python, AI Development, Generative AI, Artificial Intelligence, etc.. I hold a degree in Bachelor of Applied Science (BASc). Some of the notable projects I've worked on include: FastAPI Backend for Internal SaaS Tool, Support Ticket Processing System, Francoplus Novice Path: sleek app built with the Lovable framework, Custom Chat AI Assistant using Ollama and LangChain, browser-based survival strategy game built using AI, etc.. I am based in Antalya, Turkey. I've successfully completed 9 projects while developing at Softaims.
I possess comprehensive technical expertise across the entire solution lifecycle, from user interfaces and information management to system architecture and deployment pipelines. This end-to-end perspective allows me to build solutions that are harmonious and efficient across all functional layers.
I excel at managing technical health and ensuring that every component of the system adheres to the highest standards of performance and security. Working at Softaims, I ensure that integration is seamless and the overall architecture is sound and well-defined.
My commitment is to taking full ownership of project delivery, moving quickly and decisively to resolve issues and deliver high-quality features that meet or exceed the client's commercial objectives.
Leave a Comment
Need help building your team? Let's discuss your project requirements.
Get matched with top-tier developers within 24 hours and start your project with no pressure of long-term commitment.






