Fractal Ideas

←  Back to index

Aymeric Augustin July 20, 2021 8 min read

This is part there in a three-part series:

  1. Making React and Django play well together
  2. Making React and Django play well together - the “hybrid app” model
  3. 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:

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 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:

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:

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:

// 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.


  1. 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. 

  2. 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. 

  3. 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. 

  4. 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. 

  5. 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. 

  6. 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. 

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

  8. 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. 

  9. 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. 

  10. If you needed a reason for not giving access to the tag manager to your entire marketing department, there you go! 

django react

Did you learn something? Share this post on Twitter!

Follow us if you'd like to be notified of future posts :-)

←  Back to index