Practical, Django-specific defenses against the OWASP Top 10. Real attack examples and the exact code, settings, and tools you need to prevent them in production.
The OWASP Top 10 is the security industry's consensus list of the most critical web application risks, and it is the right lens for hardening a Django app because it focuses your effort on what attackers actually exploit. Django gives you strong defaults, but defaults can be bypassed, misconfigured, or undermined by application code. This tutorial walks the Top 10 through a Django lens — what each risk means, where Django protects you, and where you must do the work yourself.
Security is unbounded — you could spend forever on it — so you need to prioritize, and the OWASP Top 10 is the most widely respected prioritization of real-world risk. Django was designed with security in mind and ships protections for many of these risks out of the box, which is a major reason to build on it. But "secure by default" is not "secure no matter what." Every default can be turned off, worked around, or negated by insecure application code, and many breaches happen in the custom logic Django cannot protect for you. The goal of this walkthrough is to know exactly where the framework has your back and where the responsibility is yours.
The most common serious flaw is broken access control: a user accessing data or actions they should not. In Django this looks like a view that fetches an object by ID from the URL without checking the requester owns it — change the ID, see someone else's order. Django's authentication tells you who a user is; authorization — what they may do — is largely your responsibility. Always scope querysets to the current user, check object-level permissions, and never trust an ID from the request to imply authorization. This is the number-one risk for a reason, and it lives almost entirely in your application logic, so it demands deliberate attention on every view that touches user-owned data.
Make ownership part of the query itself, so an object the user does not own is indistinguishable from one that does not exist. Returning 404 rather than 403 also avoids confirming that an ID is valid. In class-based views, put the same filter in get_queryset(): DetailView, UpdateView and DeleteView all resolve the object through it, so one override protects read, edit and delete.
@login_required
def order_detail(request, pk):
order = get_object_or_404(Order, pk=pk, owner=request.user)
return render(request, "orders/detail.html", {"order": order})
class OrderUpdate(LoginRequiredMixin, UpdateView):
model = Order
fields = ["shipping_address"] # explicit list, never "__all__"
def get_queryset(self):
return super().get_queryset().filter(owner=self.request.user)
Watch two edge cases. Mass assignment: a ModelForm with fields = "__all__" lets a user set owner or status simply by adding a form field. Indirect references: an endpoint taking a line_item_id must check that the line item belongs to an order the user owns, not merely that the order in the URL is theirs.
Sensitive data exposed through weak or absent cryptography is the next major risk. The essentials: serve everything over HTTPS so data is encrypted in transit, enable HSTS so browsers refuse plain HTTP, hash passwords with a strong algorithm (Django's default password hashing is excellent — do not replace it with something weaker), and encrypt particularly sensitive data at rest. Never invent your own cryptography or store passwords reversibly. Django handles password hashing well by default, but transport security, HSTS, and protecting other sensitive fields are configuration and design choices you must make. Cryptographic failures are often failures to use available protection, not failures of the cryptography itself.
Injection — most famously SQL injection — happens when untrusted input is interpreted as code or query structure. Django's ORM parameterizes queries, so ordinary ORM use is safe by construction, which eliminates most SQL injection risk. The danger reappears when you drop to raw() SQL or string-format queries by hand, or use extra() carelessly: there, unsanitized input becomes injectable. The rule is to use the ORM's parameterization and never build SQL by concatenating user input. The same principle extends to other injection types — command injection, template injection — wherever you pass user data into something that interprets it. Django protects the common path; you must not undo that protection on the uncommon ones.
When you need SQL the ORM cannot express, pass values as parameters (%s on every backend, unquoted). Identifiers such as sort columns cannot be parameterized, so map user choices through an allowlist:
SORTS = {"newest": "created_at DESC", "total": "total DESC"}
def recent_orders(user_id, sort_key):
order_by = SORTS.get(sort_key, "created_at DESC")
with connection.cursor() as cursor:
cursor.execute(
f"SELECT id, total FROM shop_order WHERE owner_id = %s ORDER BY {order_by}",
[user_id],
)
return cursor.fetchall()
The f-string is safe only because its content comes from a fixed dictionary; reviewers should treat any formatting inside SQL as something to verify. For OS commands, call subprocess.run() with an argument list and the default shell=False.
Some vulnerabilities are not bugs but flaws in the design itself — a password reset that leaks whether an email exists, a workflow that trusts a client-supplied price, a feature with no rate limiting on a sensitive action. No amount of careful coding fixes an insecure design, because the weakness is in the plan. The defense is threat modeling: thinking through how a feature could be abused before building it, and designing in controls — rate limits, server-side validation of anything that matters, least privilege. Insecure design is in the Top 10 to remind teams that security must be considered at design time, not bolted on afterward; the cheapest vulnerability to prevent is the one you design out.
A concrete example: at checkout, treat the posted form as a statement of intent only. Look up the price from the database, validate the quantity against business limits, and decrement stock inside transaction.atomic() with select_for_update() so two concurrent orders cannot both claim the last item. Also note that Django's PasswordResetView already shows the same page whether or not the email exists — keep custom signup and reset flows equally uninformative.
Misconfiguration is among the most common and most preventable risks. In Django the classic mistakes are running with DEBUG = True in production (which leaks stack traces and settings), a weak or exposed SECRET_KEY, a permissive ALLOWED_HOSTS, default admin paths left wide open, and missing security headers. Django's manage.py check --deploy audits many of these for you. The fix is disciplined configuration: debug off, secrets in environment variables, security middleware enabled, headers set, admin protected. Misconfiguration is low-hanging fruit for attackers precisely because it requires no clever exploit — just a developer who forgot a setting — so a deployment checklist that catches these is high-value security work.
DEBUG = False
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # fails at boot if missing
ALLOWED_HOSTS = ["app.example.com"]
CSRF_TRUSTED_ORIGINS = ["https://app.example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 3600 # raise to a year once verified
Start HSTS short: browsers cache the policy for its full duration, so a mistake cannot be rolled back quickly. Set SECURE_PROXY_SSL_HEADER only when your proxy always overwrites that header, otherwise a client can claim to be on HTTPS. If a deploy warning is deliberately not applicable, silence that one ID with SILENCED_SYSTEM_CHECKS and a comment, so the rest of the check stays clean enough to fail CI.
Much of your real attack surface is code you did not write — your dependencies. A known vulnerability in a package in your tree is as exploitable as a hole in your own code, and the supply chain is a favored attack vector. Pin your dependencies, scan them continuously against vulnerability databases, and update promptly when fixes land. Be cautious about adding packages, preferring well-maintained ones. Keeping Django itself current matters especially, since security releases fix exactly the kinds of flaws attackers look for. This risk is about hygiene and vigilance rather than clever defense: the most carefully written app is compromised just as thoroughly through a single neglected vulnerable dependency.
Automate it: install from a lock file with pip install --require-hashes, run pip-audit against that lock file on every push and nightly (new advisories appear for versions you already run), and let an update bot open pull requests that your test suite validates. On Django 5.2 LTS, take every 5.2.x release promptly, since point releases are where security fixes land.
Weaknesses in how you authenticate users — allowing weak passwords, no protection against brute-force credential stuffing, insecure session handling, missing multi-factor authentication on sensitive accounts — are a major risk. Django provides solid session management and password validation, but you must enable and tune them: enforce password strength, rate-limit and lock out repeated failed logins, secure session cookies (httpOnly, Secure, SameSite), and offer or require MFA for privileged accounts. Authentication is the front door, and these failures are how attackers walk through it with stolen or guessed credentials. Use Django's building blocks, but configure them for your threat model rather than accepting the bare defaults.
Concretely: set MinimumLengthValidator to at least 12 in AUTH_PASSWORD_VALIDATORS, and remember that validators run in Django's forms but not in User.set_password() — custom views must call validate_password() themselves. After a password change, update_session_auth_hash() keeps the current session while invalidating the user's other sessions. Always log users in through django.contrib.auth.login(), which rotates the session key to prevent session fixation.
This risk covers trusting code or data without verifying its integrity — insecure deserialization, unsigned updates, a CI/CD pipeline that pulls unverified artifacts. In a Django context, be wary of deserializing untrusted data (never unpickle data from a user), verify the integrity of anything you fetch and execute, and secure your deployment pipeline so what reaches production is what you reviewed. The broader point is to not implicitly trust data and code from outside your control: validate, verify signatures where possible, and treat the build and deploy path as part of your security perimeter. Integrity failures let attackers slip malicious code or data into a system that assumed everything it received was trustworthy.
When you must hand data to a client and get it back unchanged — an unsubscribe link, multi-step form state — sign it with django.core.signing, which uses JSON rather than pickle and rejects tampered or expired values:
token = signing.dumps({"uid": user.pk}, salt="newsletter.unsubscribe")
try:
data = signing.loads(token, salt="newsletter.unsubscribe", max_age=30 * 86400)
except signing.BadSignature: # also covers SignatureExpired
data = None
Use a distinct salt per purpose, and remember signing proves integrity, not secrecy: the payload is readable by anyone. Configure task queues to use the JSON serializer, and verify inbound webhook signatures over the raw body with hmac.compare_digest().
You cannot respond to an attack you cannot see, and inadequate logging and monitoring is itself a Top 10 risk because it turns a containable incident into an undetected breach. Log security-relevant events — authentication failures, access-control denials, suspicious input — and monitor them so an attack in progress raises an alert rather than going unnoticed for months. Ensure logs are protected and retained, and never log sensitive data into them. This connects security to observability: the same instrumentation that helps you debug also helps you detect and investigate attacks. Without it, you learn about breaches from outsiders, long after the damage is done.
Django's auth signals give one place to emit security events. Django already scrubs the password out of the credentials it sends:
security_log = logging.getLogger("myproject.security")
@receiver(user_login_failed)
def on_login_failed(sender, credentials, request=None, **kwargs):
security_log.warning("login_failed", extra={
"username": credentials.get("username"),
"ip": request.META.get("REMOTE_ADDR") if request else None,
})
Also route Django's django.security loggers (CSRF failures, disallowed hosts) to the same place. Alert on failed-login spikes across many usernames (credential stuffing), many failures on one account, and bursts of 404s on object URLs from one user, which may indicate ID enumeration against your access controls.
SSRF rounds out the list: tricking your server into making requests to places it should not, such as internal services or cloud metadata endpoints, by supplying a malicious URL to a feature that fetches remote content. If your app fetches URLs on a user's behalf — webhooks, link previews, importing from a URL — validate and restrict the targets: allowlist permitted hosts, block internal address ranges, and do not blindly follow redirects. SSRF is dangerous because the request originates from inside your trusted network, where it may reach systems an external attacker cannot. Any feature that turns user input into an outbound request needs deliberate guarding against being pointed somewhere it should never go.
Check the IP the hostname resolves to, not the hostname string, and refuse redirects so each hop would need re-validation:
def assert_public_url(url: str) -> None:
parts = urlsplit(url)
if parts.scheme not in {"http", "https"} or not parts.hostname:
raise ValueError("unsupported URL")
for info in socket.getaddrinfo(parts.hostname, parts.port or 443):
if not ipaddress.ip_address(info[4][0]).is_global:
raise ValueError("non-public address")
assert_public_url(url)
resp = requests.get(url, timeout=5, allow_redirects=False)
This has a known gap: the name is resolved again when connecting, and DNS can answer differently. For strong guarantees, send outbound fetches through an egress proxy that enforces the rules at connection time, or run the fetch worker in a network segment with no route to internal services.
Two classic web attacks deserve mention because Django defends them well by default. CSRF — tricking a logged-in user's browser into submitting an unwanted request — is blocked by Django's CSRF middleware and tokens, as long as you do not disable them. XSS — injecting malicious scripts — is mitigated by Django's template auto-escaping, which escapes variables by default; the danger returns when you mark content safe or render user input as raw HTML without sanitizing it. These protections are strong but defeatable by application code that opts out of them. Respect the defaults — keep CSRF on, do not mark untrusted content safe — and Django handles two of the most common attacks for you.
To pass data to JavaScript, use {{ data|json_script:"chart-data" }} instead of writing values into a script block, and build HTML in Python with format_html(), which escapes each argument. For legitimate rich text, sanitize on save with an allowlist-based sanitizer. Reserve @csrf_exempt for endpoints not authenticated by cookies, such as signed webhooks.
Pulling it together into action: run manage.py check --deploy and fix what it flags; ensure DEBUG is off and secrets are in the environment; serve over HTTPS with HSTS and security headers; scope every queryset and check object permissions; keep the ORM's parameterization and never hand-build SQL with user input; enforce strong passwords, login rate limiting, and MFA for admins; keep dependencies patched and Django current; log and monitor security events; and guard any feature that fetches URLs against SSRF. Working through this list closes the great majority of real-world risk, because it maps directly onto what attackers actually exploit.
No single control is perfect, which is why secure systems layer defenses so that the failure of one does not mean compromise. An attacker who gets past your input validation still faces parameterized queries; one who bypasses an access check still faces an audit log that surfaces the anomaly. This layering — defense in depth — is the mindset that underlies the whole Top 10: you are not looking for one silver bullet but building overlapping protections so that a gap in any one layer is caught by another. Adopting this philosophy changes how you review code and design features, always asking what the next line of defense is when the first one fails.
A set of HTTP response headers instructs the browser to enforce protections on your behalf, and configuring them is high-value, low-effort hardening. A Content Security Policy restricts where scripts and resources may load from, sharply limiting the impact of any cross-site scripting that slips through. Strict-Transport-Security forces HTTPS. Headers controlling framing prevent clickjacking, and others stop content-type sniffing and control referrer leakage. Django's security middleware and settings make most of these straightforward to set. Because these headers turn the browser itself into an enforcement point for your security policy, configuring them well adds a meaningful layer of protection that operates entirely on the client side.
In Django 5.2 several are on by default — X_FRAME_OPTIONS = "DENY", SECURE_CONTENT_TYPE_NOSNIFF, SECURE_REFERRER_POLICY = "same-origin" — but Content Security Policy has no built-in setting, so add it via a maintained package or a small middleware. Roll it out as Content-Security-Policy-Report-Only first, fix what it reports, then enforce. Verify headers on a real deployed response, because a proxy or CDN can strip or duplicate them.
Rate limiting is often thought of as a performance measure, but it is squarely a security control too. Limiting how often an action can be attempted blunts brute-force attacks on passwords, credential stuffing, enumeration of accounts or resources, and abuse of expensive endpoints. A login endpoint without rate limiting invites unlimited password guessing; with it, an attacker is throttled to impracticality. Applying rate limits to authentication, password reset, and other sensitive or costly actions closes off whole categories of automated attack. Treating rate limiting as part of your security posture, not just capacity management, is an important piece of defending the actions attackers most want to hammer.
Limit on two keys: per client address (one source trying many accounts) and per username (many sources trying one account). Counters must live in a shared cache such as Redis; the default local-memory cache counts per process. Prefer temporary slowdowns over hard lockouts, which let anyone lock a victim out, and return HTTP 429 with the same message whether or not the account exists.
Security that is never tested is security you only hope you have. Beyond writing secure code, validate it: run automated security scanners against your application and dependencies in your pipeline, use Django's deployment checks, and for higher-stakes applications commission penetration testing where skilled testers actively try to break in. Bug bounty programs enlist outside researchers to find what you missed. The common thread is that security must be actively verified, not assumed, because the gap between "we wrote it carefully" and "it actually resists attack" is exactly where breaches live. Building security testing into your process turns your defenses from untested intentions into verified protections.
Your own tests keep fixed issues fixed. Note that Django's test client skips CSRF checks unless asked:
class OrderSecurityTests(TestCase):
def test_other_user_gets_404(self):
self.client.force_login(self.bob)
resp = self.client.get(f"/orders/{self.alice_order.pk}/")
self.assertEqual(resp.status_code, 404)
def test_post_without_csrf_token_rejected(self):
client = Client(enforce_csrf_checks=True)
client.force_login(self.alice)
resp = client.post(f"/orders/{self.alice_order.pk}/edit/", {})
self.assertEqual(resp.status_code, 403)
Run manage.py check --deploy --fail-level WARNING against production settings as a CI step, and whenever a security issue is reported, write the failing test first.
Decide the response steps before you need them. Contain first, preserve evidence (snapshot logs before changing anything), then recover:
SECRET_KEY: deploy a new key and do not list the leaked one in SECRET_KEY_FALLBACKS; accept that sessions and reset links signed with it become invalid.set_unusable_password(); changing the password invalidates its existing sessions.Afterwards, ask which control failed and which test or check would have caught it — then add it.
The OWASP Top 10 focuses security effort where attackers actually strike, and Django gives you a strong head start — parameterized queries, CSRF protection, template auto-escaping, solid password hashing, and session management — but every default can be undone by application code or misconfiguration. The risks that live mostly in your hands are broken access control (scope and authorize every data access), insecure design (threat-model before building), misconfiguration (debug off, secrets safe, headers on, verified with check --deploy), authentication hardening (strong passwords, rate limits, MFA), dependency hygiene, and guarding URL-fetching features against SSRF. Respect Django's built-in defenses by not opting out of them, log and monitor so attacks are seen, and work the practical checklist. Security is never finished, but aligning your effort with the Top 10 ensures the work you do counts against the threats that matter most.