Django Security Explained Simply: 11 Ways to Protect Your App
CSRF, SQL injection, XSS, HTTPS, secrets and more — Django security explained in plain English with everyday examples, code you can copy and interview questions.
SammyFold AI
👋 Hi! Looking to build something great? Ask me about our services, pricing, or how we can help your business.
Tuples, the mutable-default trap, generators, the GIL, middleware and multi-tenant DRF architecture — explained with everyday examples, runnable code and interview-ready answers.
Some interview questions look simple — "what's the difference between a tuple and a list?" — but the way you answer shows whether you've only read about Python or have built real systems with it. This post walks through 12 of those questions, from Python's core behaviour to how professional Django apps keep each customer's data separate.
Every topic follows the same pattern:
New to programming? Read Part 1 first; it builds the foundations. Part 2 is about architecture, so take it slowly and come back to it.
In plain English: a tuple is like a printed receipt — once it's printed, the items don't change. A list is like a shopping basket — you keep adding, removing and rearranging things.
point = (3, 7) # tuple — round brackets, fixed
basket = [3, 7] # list — square brackets, changeable
basket.append(10) # fine → [3, 7, 10]
point.append(10) # AttributeError: tuples can't be changed
| Tuple | List | |
|---|---|---|
| Can change after creation? | No (immutable) | Yes (mutable) |
| Can be a dictionary key? | Yes, if its items can | No |
| Memory & speed | Slightly smaller and faster | Built for growing |
| What it tells other developers | "This is a fixed record" | "This collection will change" |
Because tuples can't change, they can be used as dictionary keys — handy for things like map coordinates:
city_at = {(5.6037, -0.1870): 'Accra', (6.6885, -1.6244): 'Kumasi'}
Senior answer: "The real difference is mutability and intent. I use a tuple for a fixed record — coordinates, RGB colours, multiple return values, dictionary keys — and a list when I'll append, sort or filter it."
In plain English: a variable in Python is a name tag, not a box. When you pass a variable into a function, the function gets another name tag on the same object — not a copy.
What happens next depends on whether that object can change:
def add_one(number):
number = number + 1 # creates a NEW int; only the local tag moves
return number
def add_item(items):
items.append('mango') # changes the SAME list the caller has
count = 5
add_one(count)
print(count) # 5 — ints are immutable, the caller is untouched
fruits = ['apple']
add_item(fruits)
print(fruits) # ['apple', 'mango'] — the caller sees the change
int, str, tuple): "changing" them inside a function actually creates a new object, so the caller's value stays the same.list, dict, set, your own classes): changing them in place is visible to the caller, because both names point at the same object.Senior answer: "Python passes references to objects — sometimes called call by sharing. Whether the caller sees a change depends on whether the object is mutable and whether the function mutates it in place or rebinds the name."
def f(x=[])This is one of the most famous Python bugs. Look at this function:
def add_item(item, bucket=[]):
bucket.append(item)
return bucket
You'd expect every call to start with an empty bucket. Here's what actually happens:
add_item('a') # ['a']
add_item('b') # ['a', 'b'] ← surprise!
add_item('c') # ['a', 'b', 'c'] ← it keeps growing
In plain English: the default [] is created once, when Python first reads the def line — not every time you call the function. It's like a restaurant that puts one shared plate on the counter for every customer who doesn't bring their own. Everyone's food piles onto the same plate.
The fix: use None as a placeholder and create a fresh list inside:
def add_item(item, bucket=None):
if bucket is None:
bucket = [] # a brand-new list on every call
bucket.append(item)
return bucket
Why this hurts in real apps: a Django view or background worker runs the same function thousands of times without restarting. The shared list quietly grows and leaks data between users — and a quick manual test (which calls it only once) never catches it.
Senior answer: "Default values are evaluated once at definition time, so a mutable default becomes hidden shared state. I use
Noneas a sentinel and create the object inside the function."
yield really doesIn plain English: an iterator is anything that can hand you items one at a time — like a ticket machine giving out the next number each time you press the button. A generator is the easy way to build one: write a normal function, but use yield instead of return.
def countdown(n):
while n > 0:
yield n # hand out n, then PAUSE right here
n -= 1
print("done")
Let's step through it:
gen = countdown(3) # nothing runs yet — you just get a generator object
next(gen) # 3 (runs until the first yield, then pauses)
next(gen) # 2 (resumes after yield, n is remembered)
next(gen) # 1
next(gen) # prints "done", then raises StopIteration — it's finished
The magic: each next() continues exactly where it paused, with all its local variables (like n) still intact. A for loop simply calls next() for you and stops at StopIteration:
for number in countdown(3):
print(number) # 3, 2, 1, then "done"
Senior answer: "Every generator is an iterator, but not every iterator is a generator. An iterator is any object with
__iter__and__next__; a generator function builds one for me and keeps its state paused betweennext()calls."
In plain English: imagine moving 1 million bricks. A list is like loading all of them onto a giant truck before anyone can start building. A generator is a conveyor belt — each brick goes straight to the builder, and building starts immediately.
def read_rows(path):
with open(path) as f:
for line in f: # reads one line at a time
yield line
def parse(rows):
for row in rows:
yield row.strip().split(',')
def only_paid(records):
for record in records:
if record[2] == 'paid':
yield record
# Nothing is read until the loop asks for the next item — memory stays tiny
for order in only_paid(parse(read_rows('orders.csv'))):
save(order)
| Generators win | Trade-offs to remember |
|---|---|
| Same small memory for 1 thousand or 100 million rows | You can only loop through once (make a new one to repeat) |
| Each stage plugs into the next | No len(), no indexing like rows[5] |
| Work starts before reading finishes | Errors appear later, when the item is reached |
Works beautifully with itertools |
Open files or database cursors need proper closing |
Senior answer: "At scale the question isn't speed, it's memory and latency. Generators give constant memory and let downstream stages start immediately, at the cost of single-pass iteration and no random access."
In plain English:
Python (the standard version, CPython) has the GIL — the Global Interpreter Lock. Think of it as a single kitchen knife: only one thread can run Python code at any instant.
| Kind of work | Examples | Use |
|---|---|---|
| I/O-bound (mostly waiting) | API calls, database queries, reading files | threading or asyncio |
| CPU-bound (mostly calculating) | Image processing, big maths loops | multiprocessing (separate processes, each with its own GIL) or C extensions like NumPy |
| Mixed | Orchestrating jobs plus heavy computing | asyncio plus a process pool |
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
with ThreadPoolExecutor() as pool: # many downloads at once (waiting-heavy)
pages = list(pool.map(download, urls))
with ProcessPoolExecutor() as pool: # heavy maths on every CPU core
results = list(pool.map(crunch_numbers, datasets))
Good to know: newer Python versions (3.13 and later) offer an optional "free-threaded" build without the GIL, but most production systems still run the standard build — so this answer still matters in interviews.
Senior answer: "Concurrency is about structure, parallelism is about simultaneous execution. Because of the GIL, threads help I/O-bound work but not CPU-bound work; for CPU-bound work I use multiprocessing or native extensions."
Imagine an apartment building. One building (your app), one set of pipes and electricity (your code and database), but many families (tenants) living in separate flats. Each family must only ever get into their own flat.
A multi-tenant app is one system serving many companies — for example a school-management app used by 50 schools, where School A must never see School B's students. The next six questions are about making that separation automatic and reliable.
Both run "around" your views, but at different moments:
# A context processor: adds the tenant's name to every HTML page
def tenant_name(request):
tenant = getattr(request, 'tenant', None)
return {'tenant_name': tenant.name if tenant else ''}
| Middleware | Context processor | |
|---|---|---|
| Runs for | Every request | Only template-rendered pages |
| Can block a request? | Yes | No |
| Affects APIs (JSON)? | Yes | No |
| Best for | Authentication, tenant lookup, logging, rate limits | Display values like a logo or name in the navbar |
Senior answer: "Middleware is the source of truth and runs everywhere; a context processor only exposes already-resolved values to templates. They're often layered: middleware sets
request.tenant, a context processor displays it."
request.tenant be set?In custom middleware, early in the list. It works out which tenant the request belongs to — from the subdomain (schoola.myapp.com), a header, or a login token — before any view runs.
from django.http import HttpResponseNotFound
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
subdomain = request.get_host().split('.')[0] # "schoola" from schoola.myapp.com
tenant = Tenant.objects.filter(subdomain=subdomain, is_active=True).first()
if tenant is None:
return HttpResponseNotFound('Unknown tenant') # stop here, no data touched
request.tenant = tenant # every view can now use it
return self.get_response(request)
Why middleware?
request.tenant.In plain English: a bank doesn't rely on one lock. It has a gate, a guard, a vault door and cameras. If one fails, the next one catches the problem. Tenant safety works the same way — five layers:
| Layer | Job | Catches |
|---|---|---|
| 1. Middleware | Finds the tenant, sets request.tenant |
Unknown tenants |
| 2. Model manager / queryset | Makes "filter by tenant" the easy default | Developers forgetting to filter |
| 3. Base ViewSet | Filters get_queryset() once for every API view |
Standard list / detail / update / delete |
| 4. Object permission | Re-checks obj.tenant == request.tenant |
Custom actions that skip the queryset |
| 5. Serializer | tenant is read-only, set by the server |
Clients trying to send another tenant's ID |
A queryset helper keeps the "right way" short and obvious:
class TenantQuerySet(models.QuerySet):
def for_tenant(self, tenant):
return self.filter(tenant=tenant)
class Order(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
# ...
objects = TenantQuerySet.as_manager()
Order.objects.for_tenant(request.tenant) # the obvious, safe way to query
Why one layer isn't enough: filtering only in
get_queryset()protects the standard API routes, but not a custom@action, a raw query, or a future developer writingOrder.objects.get(pk=pk). Layers make safety the default, not something people must remember.
Several parts of Django and DRF could work out the tenant, but only one should be the resolver; the rest just use the answer:
request.tenant)request.tenant?"request.tenant."Why not the others as the resolver?
In Django REST Framework, the actions list, retrieve, update, partial_update and destroy all get their data through one method: get_queryset() (and get_object(), which calls it). So if you filter it once in a base class, every viewset that inherits from it is protected.
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
class TenantScopedQuerySetMixin:
def get_queryset(self):
return super().get_queryset().filter(tenant=self.request.tenant)
class BaseTenantViewSet(TenantScopedQuerySetMixin, viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsTenantObjectOwner]
def perform_create(self, serializer):
serializer.save(tenant=self.request.tenant) # the server decides the tenant, never the client
class OrderViewSet(BaseTenantViewSet): # protected automatically
queryset = Order.objects.all()
serializer_class = OrderSerializer
What each action gets for free:
In plain English: a user at School A changes the address from /orders/12/ to /orders/123/, hoping to see School B's order. Three walls stop them:
Wall 1 — queryset scoping. Order 123 isn't in School A's queryset, so get_object() returns 404 before anything else runs. To School A, it doesn't exist.
Wall 2 — object-level permission. A safety net for any code path that skips the queryset (custom actions, manual lookups):
from rest_framework.permissions import BasePermission
class IsTenantObjectOwner(BasePermission):
def has_object_permission(self, request, view, obj):
return getattr(obj, 'tenant_id', None) == request.tenant.id
Wall 3 — serializer validation. tenant is read-only, and any linked record must belong to the same tenant, so a guessed ID can't connect data across schools:
class OrderSerializer(serializers.ModelSerializer):
class Meta:
model = Order
fields = ['id', 'project', 'amount', 'tenant']
read_only_fields = ['tenant']
def validate_project(self, project):
if project.tenant_id != self.context['request'].tenant.id:
raise serializers.ValidationError('Project not found.')
return project
Don't just trust it — test it on every endpoint:
class TenantIsolationTests(APITestCase):
def setUp(self):
self.school_a = Tenant.objects.create(name='School A', subdomain='a')
self.school_b = Tenant.objects.create(name='School B', subdomain='b')
self.other_order = Order.objects.create(tenant=self.school_b, amount=100)
self.client.force_authenticate(user=make_user(tenant=self.school_a))
def test_cannot_read_another_tenants_order(self):
response = self.client.get(f'/api/orders/{self.other_order.id}/', HTTP_HOST='a.myapp.com')
self.assertEqual(response.status_code, 404)
Seed two tenants, log in as a user from one, then hit every action — including custom @actions — with the other tenant's IDs, and check you always get 404/403 and can never link records across tenants. Put the checks in a reusable test mixin so every new endpoint inherits them.
Dictionary keys must be hashable, which requires that the value never changes. Tuples are immutable (as long as their items are too), so they're hashable; lists can change, so they aren't.
['x', 'x'] — the default list was created once and is shared between calls. Use bucket=None and create the list inside the function.
None of its body runs. You get a generator object; the code only runs (up to the next yield) each time next() is called.
Processes (multiprocessing / ProcessPoolExecutor). Resizing is CPU-bound, and the GIL stops threads from running Python code in parallel.
A 403 confirms that the object exists, which leaks information. A 404 makes the other tenant's data indistinguishable from data that doesn't exist.
Read next: Python & Django interview questions explained for beginners and Django security explained simply.
Based on Mr. Agyemang Samuel's interactive engineering notes — open them to play with the live diagrams. Building a multi-tenant SaaS for several clients or branches? Talk to SammyFold.
Works in Chrome and Edge on Windows, Mac and Linux.
On another device? Open sammyfoldtech.com in Chrome on your Android phone first.
On another device? Open sammyfoldtech.com in Safari on your iPhone first.