Making React and Django play well together — the “single page app” model

This is the third part of my discussion 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.¹

Django

Start a shell, go to the root of the backend repository and bootstrap the backend:

# in the backend repository
pipenv install django
pipenv shell django-admin
startproject todolist .

Start the development server:

# in the backend repository, after executing pipenv 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.

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:

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 authentication², CSRF protection³, 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
pipenv install 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 app.example.com in production settings
CORS_ORIGIN_WHITELIST = ['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:⁴

# 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/.⁵
  • The HTML isn’t generated by Django so there’s no way to inject the cookie in the DOM.⁶

Instead we’re going to get the CSRF token from a dedicated API endpoint.⁷

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 the csrf view and caches it.
  • testRequest makes an AJAX request to the ping view. If it’s a POST request, then testRequest adds the CSRF token in a X-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.⁸

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 small 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.⁹

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 slightly 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 attack¹⁰, gaining a bit of defense in depth may be worth the effort.


Originally published at fractalideas.com.