React and Django are great choices in their respective spaces. Both also attempt to provide a complete framework for building an app:
- With create-react-app,
yarn startruns a development server on http://localhost:3000;
- With Django,
django-admin runserverdoes the same on http://localhost:8000.
As a consequence, neither side has a compelling story for working with the other:
- create-react-app points to third-party tutorials for Node and Ruby on Rails backends. It also provides an option to proxy API requests, intended to avoid CORS issues in development — which sounds like a great way to discover them in production!
- Django provides the staticfiles contrib app. While powerful and flexible, it’s designed for working with files on the filesystem and doesn’t integrate easily with a development server that doesn’t write compiled files to disk.²
Since frameworks fall short when it comes to gluing together frontend and backend, developers tend to come up with kludgy development setups, only to realize they don’t work in production.
Often this leads to shotgun debugging and sometimes to security vulnerabilities. (If you’re concerned, we can audit your app and find such issues.)
Broadly speaking four architectures make sense:³
- Running the frontend and the backend on distinct origins
In this example, the browser loads the HTML page and static assets from https://app.example.com. It makes cross-origin API requests to https://api.example.com. Most Single-Page Apps (SPA) use this architecture.
- Making the backend serve static files for the frontend
This is Django’s default behavior in development:
runserverserves static assets with a WSGI middleware provided by the staticfiles app. whitenoise provides a production-ready implementation of that behavior.
- Making the frontend proxy API requests to the backend
Traditional production deployments of Django use this architecture. With Apache and mod_wsgi, Apache serves static files and proxies other requests to mod_wsgi. With nginx and gunicorn, uWSGI or waitress, nginx serves static files and proxies other requests to the application server.
- Dispatching frontend and backend requests with a reverse proxy
This setup is less common than the others. It happens when a CDN (e.g. CloudFront) serves static assets from an object storage (e.g. S3) and forwards other requests to an application server. For practical purposes, it doesn’t matter very much how static files are served, so options 3 and 4 are equivalent.
Although there are many variants, web apps tend to gravitate towards one architecture or the other. Let’s take a look at the ramifications of this choice.
Dev / prod parity
Keeping the development setup as close to the production setup as possible minimizes surprises in production.
Backend development relies on Django’s built-in development server while production uses Apache, gunicorn, uWSGI or waitress. The WSGI protocol provides a sufficiently good abstraction to consider there’s dev / prod parity.
Almost every Django project has separate development and production settings modules. Keeping these modules as similar as possible is a good practice for preserving parity at the application level.
It’s more difficult to achieve dev / prod parity on the frontend.
The development setup is self-contained and optimizes for reloading the application quickly when the code changes: incremental compilation to memory, HTTP server, hot reloading, etc.
The production setup relies on third-party infrastructure for serving the application and optimizes for application performance at the expense of build time: one-off compilation to disk, minification, hashing, etc.
Perhaps this is why developers sometimes select different architectures for development and production, especially frontend developers who are used to diverging setups.
In my experience, each architecture comes with its own set of challenges. Not only does selecting the same architecture for both development and production increase parity, but it also requires understanding and solving only one set of challenges.
Having trouble with authentication isn’t a reason to switch to another architecture. In my experience, broken authentication is usually a symptom of another issue, often a CORS or CSRF issue.
By default, Django’s user authentication system relies on cookie-based sessions. The security model of cookies is well known. Browsers provide first-class support, for example cookies can expire when the browser closes or after a given date.
When a user logs in with valid credentials, their identity is stored in the session. The browser sends the session cookie which authenticates them automatically with every subsequent HTTP(S) request.
Using Django’s built-in authentication system makes it easier to integrate additional features provided by third-party packages such as two-factor authentication.
JWTs are a popular alternative, perhaps because they remove the need to understand CSRF (more on this below).
Since JWTs are managed at the application level, each application must implement storage, expiry and renewal of JWTs. This is a significant, security-sensitive responsibility.
Even though third-party packages provide these features, the JWT ecosystem is less complete and mature than Django’s historical authentication framework.
JWTs are a good choice for managing authentication in mobile or desktop apps, including those built with web technologies such as Cordova or Electron.⁴ Users expect permanent authentication in apps, except the most sensitive ones, which alleviates concerns about managing expiry.
If the same backend serves both a web app and mobile or desktop apps, implementing both authentication mechanism can be a good tradeoff. Then the web app relies on cookies and the mobile or desktop apps rely on JWTs.
The “single page app” model requires setting up CORS because the frontend and the backend run on separate domains.
In our example, the backend server running at https://api.example.com must return the following HTTP headers:
Access-Control-Allow-Credentials is a common pitfall and a bad reason to switch from cookies to JWTs.
This is easily achieved with django-cors-headers and the following settings:
CORS_ALLOW_CREDENTIALS = True
CORS_ORIGIN_WHITELIST = ['app.example.com']
I recommend django-cors-headers rather than a custom middleware because it provides a nice abstraction with many options and it reduces the risk of making mistakes.
CSRF works identically with any web app model. It shouldn’t be a factor in the decision we’re discussing here. It’s an issue that needs solving, though.
Cookies set on a domain are automatically sent with every HTTP(S) request to that domain. This is known as ambient authority. It makes cookie-based authentication very convenient: you don’t need to do anything special on the client side.
However, if an attacker’s web page makes a cross-domain request to your website, due to ambient authority, the request will carry the user’s authentication. This is a Cross-Site Request Forgery attack.
Django defends against this attack by requiring a CSRF token that must be explicitly added to form submissions and AJAX POST requests. The browsers’ Same-Origin Policy restrictions prevent an attacker from obtaining that token from your site.
There are a few common pitfalls with the approach recommended by Django:
- Sometimes the CSRF token isn’t available in cookies: with the “single-page app” model when the HTML isn’t served by Django, with the “hybrid app” model when the first page doesn’t generate a token, etc.
- Even when there’s a CSRF token available in the DOM, sometimes it’s outdated: when logging in or out doesn’t trigger a full page reload, when logging in or out in another tab, etc.
My recommendation for avoiding these issues is:
- To read the CSRF token from the CSRF cookie before each AJAX request;
- If there’s no CSRF cookie, to make a request to a dedicated endpoint that generates a CSRF token and retry.
Assuming JWTs are stored securely, JWT-based authentication isn’t vulnerable to CSRF attacks because it doesn’t provide ambient authority, unlike cookies.
I’ve seen frustrated developers having a hard time with CSRF switch to JWTs. I don’t think that’s a good reason.
It’s hard to quantify how client-side rendering affects search ranking compared to traditional server-side rendering but it’s almost certainly a loss. Experiments show that the crawl frequency is lower. Regular HTML pages are a safer bet for the time being when SEO is a concern.
As a consequence, projects that need good SEO tend to prefer the “hybrid app” approach and render HTML in the backend.
Running the frontend and the backend separately means you can deploy them separately.
The frontend may be faster to deploy and easier to rollback than the backend, if only because there’s no need to care about database migrations. Being able to deploy the frontend much more often than the backend may increase productivity.
Conversely, deploying the application as a single unit makes it easier to synchronize changes between the frontend and the backend. It reduces concerns about compatibility between arbitrary versions of the frontend and the backend⁵ and makes it easier to track versions.
The “hybrid app” model is usually implemented by building the frontend, injecting it into the backend’s build process, and deploying the result as a single unit.
The “single page app” model encourages deploying the frontend and the backend separately. Of course, it’s always possible to automate synchronized deployments.
As an application grows, integration testing becomes critical for preventing regressions, especially in agile teams that move fast. End-to-end tests are usually written Selenium and sometimes with a BDD testing framework.
This creates a challenge when the frontend and the backend are maintained in separate code repositories, which is common in the “single-page app” model. There’s no obvious and meaningful way to run integration tests in CI for all revisions of the frontend and the backend.
The pragmatic solution there is to stabilize the API by adopting a compatibility policy, to test it thoroughly, and to focus the integration tests on preventing regressions in the frontend. In practice this provides a reasonable safety net.
This issue rarely occurs with the “hybrid app” model. Since the backend and frontend are more tightly coupled, they’re almost always maintained in the same code repository. The application can be tested as a whole at each revision.
More generally, this choice should take into account the organization and preferences of your development team.
If the frontend and backend teams are separate and coordinate with an API definition, they’ll be more comfortable with the “single page app” model, where the frontend and the backend are cleanly separated.
If full-stack developers build the application as a whole, they may find that the “hybrid app” model introduces less overhead and keeps processes simple.
If the team is led by frontend developers, they’ll often choose to keep the backend small and separate from the frontend app.
Conversely, if it grows from backend developers, they’ll naturally try to fit the frontend in the backend app like they always integrated HTML and CSS.
Considering all these human and technical factors, I believe there’s no one-size-fits-all solution. Both the “single-page app” and the “hybrid app” model can support great end-user and developer experiences.
Depending on the context, there may be strong reasons to build one or the other.
More often than not, it will be a trade-off between the factors listed above. In all cases, taking full advantage of the benefits and remaining aware of the pitfalls is key to the making the choice a success.
Stay tuned for follow-up posts describing how to implement each model with create-react-app and Django.
- While this post talks about React, everything also applies to other frontend frameworks such as Ember.
- Such servers didn’t exist in 2011 when the staticfiles app was merged into Django, let alone in 2009 when it was designed. The development of webpack started in 2012.
- These diagrams are simplified. Actual deployments may include other components such as a load balancer or a CDN.
- Cookies are available in apps built with web technologies. However, adding the CSRF token to requests isn’t always easy. This makes cookie-based authentication impractical.
- Even when the frontend and the backend are deployed together, the backend may receive API requests from browsers running an earlier version of the frontend. As a consequence, developers still need a compatibility strategy.
Originally published at fractalideas.com.