This is part there in a three-part series:
- Making React and Django play well together
- Making React and Django play well together - the “hybrid app” model
- Making React and Django play well together - the “single page app” model
Making React and Django play well together - the “single page app” model
This message continues my analysis of the trade-offs involved in choosing an architecture for integrating React with Django.
I’m focusing on the alternative between two models:
- The “single page app” model: a standalone JavaScript frontend makes API requests to a backend running on another domain;
- The “hybrid app” model: the same backend serves HTML pages embedding JavaScript components and API requests.
After the “hybrid app” model, here’s how to implement the “single page app” model.
Like last time, we’ll call the app todolist
.
Disclaimer: I’m starting with default project templates and making minimal changes in order to focus on the integration between the frontend and the backend.
As a consequence, I’m ignoring many best practices for Django and React projects. Keep in mind that I’m describing only one piece of the puzzle and that many variants are possible!
Why build a “single page app”?
In the “single page app” architecture, the frontend and the backend are maintained and deployed separately.
This architecture provides several benefits:
- It’s well known and easy to grasp by developers who specialize in either frontend or backend technologies.
- It provides more flexibility for choosing the best processes and tools. They may diverge between the frontend and the backend.
- It reduces coupling: for example, deploying a new version of the frontend can’t break the backend.
- It encourages good practices like providing compatibility across API versions.
It’s an obvious choice for large teams maintaining complex products. They need a lot of coordination to build and integrate features already. They can absorb the overhead of managing the frontend and the backend separately.
Even for smaller projects, the cost of adopting this architecture is low enough that it can be a good choice.
Initialization
Teams building single page apps usually create separate repositories for maintaining the frontend and the backend.
Let’s initialize Django and React applications in backend
and frontend
repositories.1
Django
Start a shell, go to the root of the backend repository and bootstrap the backend:
# in the backend repository
poetry init
poetry shell
poetry add django
django-admin startproject todolist .
Start the development server:
# in the backend repository, after executing poetry shell
./manage.py migrate
./manage.py runserver
Open http://localhost:8000/ in a browser to confirm that everything is working.
React
Start another shell, go to the root of the frontend repository and bootstrap the frontend:
# in the frontend repository
npx create-react-app .
Start the development server:
# in the frontend repository
yarn start
http://localhost:3000/ opens automatically in a browser.
Setup
The “single page app” model provides great dev / prod parity. I don’t even need separate “Production setup” and “Development setup” sections!
The frontend serves initial HTML and static assets. The backend serves API requests. They don’t communicate with one another directly.
You can deploy the frontend and the backend to production according to best practices for your preferred hosting platform.
You get all the features of the development environments without any additional integration effort.
Typically:
- the frontend runs on http://localhost:3000/ in development and on https://app.example.com in production;
- the backend runs on http://localhost:8000/ in development and on https://api.example.com in production.
You should use the same configuration across environments, except for settings involving these URLs, which you should substitute consistently.
CORS
Since the frontend and the backend run on separate domains, you must set up CORS, else API requests will fail.
In development, the backend must include the following HTTP header in responses:
Access-Control-Allow-Origin: http://localhost:3000
and in production:
Access-Control-Allow-Origin: https://app.example.com
Furthermore, if you’re relying on cookies for authentication2, CSRF protection3, or any other purpose, the backend must also include:
Access-Control-Allow-Credentials: true
To achieve this, let’s install and configure django-cors-headers:
# in the backend repository
poetry add django-cors-headers
# todolist/settings.py
INSTALLED_APPS = [
...,
'corsheaders',
...
]
MIDDLEWARE = [
...,
# just after django.middleware.security.SecurityMiddleware
'corsheaders.middleware.CorsMiddleware',
...,
]
CORS_ALLOW_CREDENTIALS = True
# change to https://app.example.com in production settings
CORS_ORIGIN_WHITELIST = ['http://localhost:3000']
CSRF
Django’s CSRF protection checks the Referer
header of HTTPS requests to prevent CSRF attacks between subdomains of the same domain or between HTTP and HTTPS.
This creates an issue in our scenario. We’re planning to make requests across domains; they will fail the CSRF check.
Fortunately Django provides a setting to allow cross-domain requests from our frontend:4
# todolist/settings.py
# change to app.example.com in production settings
CSRF_TRUSTED_ORIGINS = ['localhost:3000']
Making an API request
Let’s ensure that our configuration works.
The Django documentation suggests two ways to obtain the CSRF token in order to include it in AJAX requests. Unfortunately they aren’t applicable to our setup:
- The frontend running at http://localhost:3000/ cannot read the value of the CSRF cookie for the backend at http://localhost:8000/.5
- The HTML isn’t generated by Django so there’s no way to inject the cookie in the DOM.6
Instead we’re going to get the CSRF token from a dedicated API endpoint.7
Create the following views in the backend:
# todolist/views.py
from django.http import JsonResponse
from django.middleware.csrf import get_token
def csrf(request):
return JsonResponse({'csrfToken': get_token(request)})
def ping(request):
return JsonResponse({'result': 'OK'})
Wire the new views in the URLconf:
# todolist/urls.py
from django.contrib import admin
from django.urls import path
from . import views
urlpatterns = [
path('admin/', admin.site.urls),
path('csrf/', views.csrf),
path('ping/', views.ping),
]
Now let’s build a quick test in the frontend. In the example below:
getCsrfToken
gets a CSRF token from thecsrf
view and caches it.testRequest
makes an AJAX request to theping
view. If it’s a POST request, thentestRequest
adds the CSRF token in aX-CSRFToken
header, as expected by Django.App
triggers a GET request and a POST request when it loads. If these requests succeed,App
changes the test result from KO to OK.
// src/App.js
import React, { Component } from 'react';
const API_HOST = 'http://localhost:8000';
let _csrfToken = null;
async function getCsrfToken() {
if (_csrfToken === null) {
const response = await fetch(`${API_HOST}/csrf/`, {
credentials: 'include',
});
const data = await response.json();
_csrfToken = data.csrfToken;
}
return _csrfToken;
}
async function testRequest(method) {
const response = await fetch(`${API_HOST}/ping/`, {
method: method,
headers: (
method === 'POST'
? {'X-CSRFToken': await getCsrfToken()}
: {}
),
credentials: 'include',
});
const data = await response.json();
return data.result;
}
class App extends Component {
constructor(props) {
super(props);
this.state = {
testGet: 'KO',
testPost: 'KO',
};
}
async componentDidMount() {
this.setState({
testGet: await testRequest('GET'),
testPost: await testRequest('POST'),
});
}
render() {
return (
<div>
<p>Test GET request: {this.state.testGet}</p>
<p>Test POST request: {this.state.testPost}</p>
</div>
);
}
}
export default App;
Look at the application in the browser. It should have reloaded automatically and say:
Test GET request: OK
Test POST request: OK
Hurray!
Going further
In a real application, I would write a wrapper around fetch
to handle this for all API requests.
The wrapper would detect when a request fails the CSRF check. In that case, it would refresh the CSRF token and retry the request.
To identify CSRF failures unambiguously, the easiest solution is to point CSRF_FAILURE_VIEW to a custom view that returns a HTTP 403 with a specific payload.
Perhaps you’re resisting the urge to ask…
What about JWT?
If I had chosen to rely on JWTs for authenticating users instead of cookies, I could have skipped all the CSRF-related settings.8
Indeed, JWTs are managed at the application level. JWTs are only sent by the browser to the server when the application code decides to do so.
This is unlike cookies which are managed by the browser. Cookies are sent implicitly with HTTP requests, providing the ambient authority that enables CSRF attacks.
Either way, you’ll need a wrapper around fetch
to inject a JWT or a CSRF token in AJAX requests. You’ll have to ensure that it behaves properly regardless of whether the user is logged in or logged out. Managing CSRF tokens adds a little bit of complexity but not much compared to handling authentication correctly.9
The bigger difference when switching authentication to JWTs is that the application becomes responsible for managing the storage, expiry and renewal of authentication credentials without introducing any vulnerability. The browser took care of that behind the scenes with cookies.
Even though there are off-the-shelf solutions for managing JWTs in browsers, I’m more comfortable with trusting browser vendors to get this right. They figured out many security issues over the past 25 years. They’ve become good at pushing security updates to users.
Another notable difference is that secure, http-only cookies have better security properties than JWTs stored in localStorage
or sessionStorage
.
An attacker who manages a XSS attack can trivially exfiltrate a JWT stored in localStorage
or sessionStorage
and use it to impersonate the user, even if the victim closes their browser or logs out. In contrast, if authentication relies on cookies, exploiting a XSS attack takes more work and may required continued access to the compromised browser.
In a world where social engineering whoever has access to Google Tag Manager to add a compromised tag is a viable vector for mounting a XSS attack10, gaining a bit of defense in depth may be worth the effort.
-
I will be using poetry and yarn. It’s a matter of personal preference. You can achieve the same result with virtualenv + pip and npm instead. ↩
-
Projects sometimes abandon cookie-based authentication in favor of JWT simply because no one realizes this is the issue. There may be valid reasons for choosing JWT; this isn’t one. ↩
-
If you’re using cookie-based authentication, then you need CSRF protection and you should use Django’s built-in CSRF protection. If you aren’t using cookies for authentication, in most cases, you don’t need CSRF protection. ↩
-
Strictly speaking this setting has no effect in development because HTTPS isn’t enabled. I’m including it anyway in order to minimize differences between development and production settings. ↩
-
Generally speaking JavaScript running on a given origin cannot read cookies for other origins. Here, since we control both origins, we could work around this security restriction, but that would add quite a bit of complexity. ↩
-
Even if it was possible to insert the CSRF token in the DOM, I’ve found this approach problematic in practice because the CSRF token can change without a full page reload. Then the actual CSRF token is out of sync with the value in the DOM. ↩
-
You may wonder whether this endpoint creates a security vulnerability. From a security perspective, it’s no different from any page that contains the CSRF token on a traditional Django website. The browser’s same-origin policy prevents an attacker from getting access to the token with a cross-origin request.
Only the frontend is allowed to make cross-origin requests to the backend. If an attacker could run JavaScript in the context of the frontend, that would be a XSS vulnerability. XSS is strictly worse than CSRF. A XSS vulnerability allows everything that a CSRF vulnerability allows and much more. As a consequence, we can ignore this scenario as far as CSRF protection is concerned. ↩
-
I’m discussing the scenario where the application sends a JWT authenticating the current user in an Authorization Header with each HTTP request.
It’s also possible to transmit the JWT to the server in a cookie. In that case, CSRF protection is still needed. ↩
-
CSRF and authentication have more in common than you might expect. Django rotates the CSRF token when a user logs in or logs out. This requires either refreshing the CSRF token after login or logout, or setting up a mechanism to refresh the CSRF token if it becomes stale. The latter is more reliable. ↩
-
If you needed a reason for not giving access to the tag manager to your entire marketing department, there you go! ↩