Django Session-based Auth for Single Page Apps

Last updated December 14th, 2020

In this article, we'll look at how to authenticate Single-Page Applications (SPAs) with session-based authentication. We'll be using Django for our backend while the frontend will be built with React, a JavaScript library designed for building user interfaces.

Feel free to swap out React for a different tool like Angular, Vue, or Svelte.

Contents

Session vs. Token-based Auth

What Are They?

With session-based auth, a session is generated and the ID is stored in a cookie.

After logging in, the server validates the credentials. If valid, it generates a session, stores it, and then sends the session ID back to the browser. The browser stores the session ID as a cookie, which gets sent anytime a request is made to the server.

session-based auth workflow

Session-based auth is stateful. Each time a client requests the server, the server must locate the session in memory in order to tie the session ID back to the associated user.

Token-based auth, on the other hand, is relatively new compared to session-based auth. It gained traction with the rise of SPAs and RESTful APIs.

After logging in, the server validates the credentials and, if valid, creates and sends back a signed token to the browser. In most cases, the token is stored in localStorage. The client then adds the token to the header when a request is made to the server. Assuming the request came from an authorized source, the server decodes the token and checks its validity.

token-based auth workflow

A token is a string that encodes user information.

For example:

// token header
{
  "alg": "HS256",
  "typ": "JWT"
}

// token payload
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

The token can be verified and trusted because it's digitally signed using a secret key or public/private key pair. The most common type of token is a JSON Web Token (JWT).

Since the token contains all information required for the server to verify a user's identity, token-based auth is stateless.

For more on sessions and tokens, check out Session Authentication vs Token Authentication from Stack Exchange.

Security Vulnerabilities

As mentioned, session-based auth maintains the state of the client in a cookie. While JWTs can be stored in localStorage or a cookie, most token-based auth implementations store the JWT in localStorage. Both of these methods come with potential security issues:

Storage Method Security Vulnerability
Cookie Cross Site Request Forgery (CSRF)
localStorage Cross-Site Scripting (XSS)

CSRF is an attack against a web application in which the attacker attempts to trick an authenticated user into performing a malicious action. Most CSRF attacks target web applications that use cookie-based auth since web browsers include all of the cookies associated with each request's particular domain. So when a malicious request is made, the attacker can easily make use of the stored cookies.

To learn more about CSRF and how to prevent it in Flask, check out the CSRF Protection in Flask article.

XSS attacks are a type of injection where malicious scripts are injected into the client-side, usually to bypass the browser's same-origin policy. Web applications that store tokens in localStorage are open to XSS attacks. Open a browser and navigate to any site. Open the console in developer tools and type JSON.stringify(localStorage). Press enter. This should print the localStorage elements in a JSON serialized form. It's that easy for a script to access localStorage.

For more on where to store JWTs, check out Where to Store your JWTs – Cookies vs. HTML5 Web Storage.

Setting up Session-based Auth

This tutorial covers the following approaches for combining Django with a frontend library or framework:

  1. Serve up the framework via Django templates
  2. Serve up the framework separately from Django on the same domain
  3. Serve up the framework separately from Django with Django REST Framework on the same domain
  4. Serve up the framework separately from Django on a different domain

Again, feel free to swap out React for the frontend of your choice -- i.e., Angular, Vue, or Svelte.

Frontend served from Django

With this approach we'll serve our React application directly from Django. This approach is the easiest to set up.

Backend

Let's start off by creating a new directory for our project. Inside the directory we'll create and activate a new virtual environment, install Django, and create a new Django project:

$ mkdir django_react_templates && cd django_react_templates
$ python3.9 -m venv env
$ source env/bin/activate

(env)$ pip install django==3.1.4
(env)$ django-admin.py startproject djangocookieauth .

After that, create a new app called api:

(env)$ python manage.py startapp api

Register the app in djangocookieauth/settings.py under INSTALLED_APPS:

# djangocookieauth/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api.apps.ApiConfig', # new
]

Our app is going to have the following API endpoints:

  1. /api/login/ allows the user to log in by providing their username and password
  2. /api/logout/ logs the user out
  3. /api/session/ checks whether a session exists
  4. /api/whoami/ fetches user data for an authenticated user

For the views, grab the full code here and add it to the api/views.py file.

Add a urls.py file to "api" and define the following URLs:

# api/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('login/', views.login_view, name='api-login'),
    path('logout/', views.logout_view, name='api-logout'),
    path('session/', views.session_view, name='api-session'),
    path('whoami/', views.whoami_view, name='api-whoami'),
]

Now, let's register our app URLs to the base project:

# djangocookieauth/urls.py

from django.contrib import admin
from django.shortcuts import render
from django.urls import path, include  # new import


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),  # new
]

Code for our backend is now more or less done. Run the migrate command and create a superuser for testing in the future:

(env)$ python manage.py migrate
(env)$ python manage.py createsuperuser

Finally, update the following security settings in djangocookieauth/settings.py:

CSRF_COOKIE_SAMESITE = 'Strict'
SESSION_COOKIE_SAMESITE = 'Strict'
CSRF_COOKIE_HTTPONLY = False  # False since we will grab it via universal-cookies
SESSION_COOKIE_HTTPONLY = True

# PROD ONLY
# CSRF_COOKIE_SECURE = True
# SESSION_COOKIE_SECURE = True

Notes:

  1. Setting CSRF_COOKIE_SAMESITE and SESSION_COOKIE_SAMESITE to True prevents cookies and CSRF tokens from being sent from any external requests.
  2. Setting CSRF_COOKIE_HTTPONLY and SESSION_COOKIE_HTTPONLY to True blocks client-side JavaScript from accessing the CSRF and session cookies. We set CSRF_COOKIE_HTTPONLY to False since we'll be accessing the cookie via JavaScript.

If you're in production, you should serve your website over HTTPS and enable CSRF_COOKIE_SECURE and SESSION_COOKIE_SECURE, which will only allow the cookies to be sent over HTTPS.

Frontend

Before you start working on the frontend, make sure that you have Node.js and npm (or Yarn) installed.

We'll use Create React App to scaffold out a new React project:

$ npx create-react-app frontend
$ cd frontend
$ npm start

This will start our app on port 3000. Visit http://localhost:3000 to ensure it works:

React default page

You can simplify the frontend, by removing all files and folders except:

├── README.md
├── node_modules
├── package-lock.json
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── manifest.json
│   └── robots.txt
└── src
    ├── App.js
    ├── index.css
    └── index.js

Next, let's add Bootstrap frontend/public/index.html:

<!-- frontend/public/index.html -->

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <title>React App</title>
    <!-- new -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" crossorigin="anonymous">
    <!-- end of new -->
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>

Next, we'll use universal-cookie for loading cookies into the React app.

Install it from the "frontend" folder:

$ npm install universal-cookie

Grab the full code here for the App component and add it to the frontend/src/App.js file.

This is just a simple frontend application with a form, which is handled by React state. On page load, compontentDidMount() is called which fetches the session and sets isAuthenticated to either true or false.

We obtained the CSRF token using universal-cookie and passed it as a header in our requests as X-CSRFToken:

import Cookies from "universal-cookie";

const cookies = new Cookies();

login = (event) => {
  event.preventDefault();
  fetch("/api/login/", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-CSRFToken": cookies.get("csrftoken"),
      },
      credentials: "same-origin",
      body: JSON.stringify({username: this.state.username, password: this.state.password}),
    })
    .then(this.isResponseOk)
    .then((data) => {
      console.log(data);
      this.setState({isAuthenticated: true, username: "", password: "", error: ""});
    })
    .catch((err) => {
      console.log(err);
      this.setState({error: "Wrong username or password."});
    });
}

Note that with every request we used credentials: same-origin. This is required because we want the browser to pass cookies with each HTTP request if the URL is of the same origin as the calling script.

Update frontend/src/index.js:

// frontend/src/index.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App.js";

import "./index.css";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

Serving React

First, build the frontend application:

$ npm run build

This command will generate the "build" folder which our backend will use to serve up our React application.

Next, we have to let Django know where our React app is located:

# djangocookieauth/settings.py

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR.joinpath('frontend')],  # new
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

...

STATIC_URL = '/static/'

STATICFILES_DIRS = (
    BASE_DIR.joinpath('frontend', 'build', 'static'),  # new
)

If you're using an older version of Django make sure to import os and use os.path.join instead of joinpath.

Let's create the index view for our application:

# djangocookieauth/urls.py

from django.contrib import admin
from django.shortcuts import render
from django.urls import path, include


# new
def index_view(request):
    return render(request, 'build/index.html')


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),
    path('', index_view, name='index'),  # new
]

Since Django is ultimately serving up the frontend the CSRF cookie will be set automatically.

From the project root, run the Django server using the runserver command like so:

(env)$ python manage.py runserver

Open your browser and navigate to https://localhost:8000/. Your React app is now served via Django templates.

Login page

On load, the CSRF cookie is set, which is used in subsequent AJAX requests. If the user enters the correct username and password, it authenticates them and saves the sessionid cookie to their browser.

Session page

You can test it with the superuser that you created before.

Grab the full code for this approach from GitHub: django_react_templates.

Frontend served separately (same domain)

With this approach, we'll build the frontend and serve it up separately from the Django app on the same domain. We'll use Docker and Nginx to serve up both apps on the same domain locally.

The main difference between the templates approach and this one is that we'll have to manually fetch the CSRF token on load.

Start off by creating a project directory:

$ mkdir django_react_same_origin && cd django_react_same_origin

Backend

First, create a new directory called "backend" for the Django project:

$ mkdir backend && cd backend

Next, create and activate a new virtual environment, install Django, and create a new Django project:

$ python3.9 -m venv env
$ source env/bin/activate

(env)$ pip install django==3.1.4
(env)$ django-admin.py startproject djangocookieauth .

After that, create a new app called api:

(env)$ python manage.py startapp api

Register the app in djangocookieauth/settings.py under INSTALLED_APPS:

# backend/djangocookieauth/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api.apps.ApiConfig', # new
]

Our app is going to have the following API endpoints:

  1. /api/get_csrf/ will generate a CSRF token and return it as JSON
  2. /api/login/ allows the user to log in by providing their username and password
  3. /api/logout/ logs the user out
  4. /api/session/ checks whether a session exists
  5. /api/whoami/ fetches user data for an authenticated user

For the views, grab the full code here and add it to the backend/api/views.py file.

Add a urls.py file to "backend/api" and define the following app-specific URLs:

# backend/api/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('csrf/', views.get_csrf, name='api-csrf'),
    path('login/', views.login_view, name='api-login'),
    path('logout/', views.logout_view, name='api-logout'),
    path('session/', views.session_view, name='api-session'),
    path('whoami/', views.whoami_view, name='api-whoami'),
]

Now, let's register our app URLs to the base project:

# backend/djangocookieauth/urls.py

from django.contrib import admin
from django.shortcuts import render
from django.urls import path, include  # new import


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),  # new
]

Let's change some of the security setting in backend/djangocookieauth/settings.py:

CSRF_COOKIE_SAMESITE = 'Strict'
SESSION_COOKIE_SAMESITE = 'Strict'
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_HTTPONLY = True

# PROD ONLY
# CSRF_COOKIE_SECURE = True
# SESSION_COOKIE_SECURE = True

Notes:

  1. Enabling CSRF_COOKIE_SAMESITE and SESSION_COOKIE_SAMESITE prevents cookies and CSRF tokens from being sent from any external requests.
  2. Enabling CSRF_COOKIE_HTTPONLY and SESSION_COOKIE_HTTPONLY blocks client-side JavaScript from accessing the CSRF and session cookies.

If you're in production, you should serve your website over HTTPS and enable CSRF_COOKIE_SECURE and SESSION_COOKIE_SECURE, which will only allow the cookies to be sent over HTTPS.

Create a backend/requirements.txt file:

Django==3.1.4

Frontend

Before you start working on the frontend, make sure that you have Node.js and npm (or Yarn) installed.

We'll use Create React App to scaffold out a new React project.

From the project root, run:

$ npx create-react-app frontend
$ cd frontend
$ npm start

This will start our app on port 3000. Visit http://localhost:3000 to ensure it works:

React default page

You can simplify the frontend, by removing all files and folders except:

├── README.md
├── node_modules
├── package-lock.json
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── manifest.json
│   └── robots.txt
└── src
    ├── App.js
    ├── index.css
    └── index.js

Next, let's add Bootstrap frontend/public/index.html:

<!-- frontend/public/index.html -->

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <title>React App</title>
    <!-- new -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" crossorigin="anonymous">
    <!-- end of new -->
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>

Grab the full code here for the App component and add it to the frontend/src/App.js file.

This is just a simple frontend application with a form, which is handled by React state. On page load, compontentDidMount() is called which performs two API calls:

  1. First, it checks if the user is authenticated by calling /api/session/ and sets isAuthenticated to either true or false.
  2. If the user is not authenticated it fetches the CSRF token from /api/csrf/ and saves it to the state.

Note that with every request we used credentials: same-origin. This is required because we want the browser to pass cookies with each HTTP request if the URL is of the same origin as the calling script.

Update frontend/src/index.js:

// frontend/src/index.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App.js";

import "./index.css";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

Docker

Next, let's Dockerize both apps.

Backend

# backend/Dockerfile

# pull official base image
FROM python:3.9.0-slim-buster

# set working directory
WORKDIR /usr/src/app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# install dependencies
RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip install -r requirements.txt

# add app
COPY . .

# start app
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

Frontend

# frontend/Dockerfile

# pull official base image
FROM node:15.2.0-alpine

# set working directory
WORKDIR /usr/src/app

# add `/usr/src/app/node_modules/.bin` to $PATH
ENV PATH /usr/src/app/node_modules/.bin:$PATH

# install and cache app dependencies
COPY package.json .
COPY package-lock.json .
RUN npm ci
RUN npm install [email protected] -g --silent

# start app
CMD ["npm", "start"]

Nginx

In order to run both apps on the same domain, let's add a container for Nginx that works as a reverse proxy. Create a new folder in the project root called "nginx".

# nginx/Dockerfile

FROM nginx:latest
COPY ./nginx.conf /etc/nginx/nginx.conf

Add a nginx/nginx.conf configuration file as well. You can find the code for it here.

Take note of the two location blocks:

# nginx/nginx.conf

location /api {
  proxy_pass              http://backend:8000;
  ...
}

location / {
  proxy_pass              http://frontend:8000;
  ...
}

Requests to / will be forwarded to the http://frontend:8000 (frontend is the name of the service from the Docker Compose file, which we'll add shortly) while requests to /api will be forwarded to the http://backend:8000 (backend is the name of the service from the Docker Compose file).

Docker Compose

Create a docker-compose.yml file in the project root and add the following:

# docker-compose.yml

version: '3.8'

services:

  backend:
    build: ./backend
    volumes:
      - ./backend:/usr/src/app
    expose:
      - 8000

  frontend:
    stdin_open: true
    build: ./frontend
    volumes:
      - ./frontend:/usr/src/app
      - /usr/src/app/node_modules
    expose:
      - 3000
    environment:
      - NODE_ENV=development
    depends_on:
      - backend

  reverse_proxy:
    build: ./nginx
    ports:
      - 81:80
    depends_on:
      - backend
      - frontend

Your project structure should now look like this:

├── backend
│   ├── Dockerfile
│   ├── api
│   │   ├── __init__.py
│   │   ├── admin.py
│   │   ├── apps.py
│   │   ├── migrations
│   │   │   └── __init__.py
│   │   ├── models.py
│   │   ├── tests.py
│   │   ├── urls.py
│   │   └── views.py
│   ├── djangocookieauth
│   │   ├── __init__.py
│   │   ├── asgi.py
│   │   ├── settings.py
│   │   ├── urls.py
│   │   └── wsgi.py
│   ├── manage.py
│   └── requirements.txt
├── docker-compose.yml
├── frontend
│   ├── Dockerfile
│   ├── README.md
│   ├── package-lock.json
│   ├── package.json
│   ├── public
│   │   ├── favicon.ico
│   │   ├── index.html
│   │   ├── manifest.json
│   │   └── robots.txt
│   └── src
│       ├── App.js
│       ├── index.css
│       └── index.js
└── nginx
    ├── Dockerfile
    └── nginx.conf

Running with Docker

Build the images and spin up the containers:

$ docker-compose up -d --build

If you encounter 'Service frontend failed to build', your package-lock.json might be missing. Move to the frontend folder and run npm install --package-lock in order to generate it.

Run the migrations and create a superuser:

$ docker-compose exec backend python manage.py makemigrations
$ docker-compose exec backend python manage.py migrate
$ docker-compose exec backend python manage.py createsuperuser

Your application should be accessible at: http://localhost:81. Test it by logging in with the superuser that you just created.

Grab the full code for this approach from GitHub: django_react_same_origin.

Django DRF + Frontend served separately (same domain)

This approach is more or less the same as the previous approach, "Frontend served separately (same domain)". There are a few minor differences which are listed below.

When using this approach you have to install djangorestframework using pip or add it to requirements.txt (if building with Docker). After you install it you need to register it under INSTALLED_APPS inside your settings.py.

# djangocookieauth/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api.apps.ApiConfig',
    'rest_framework', # new
]

To enable SessionAuthentication you have to add the following to your settings.py:

# backend/djangocookieauth/settings.py

# Django REST framework
# https://www.django-rest-framework.org/api-guide/settings/

REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': [
        'rest_framework.renderers.JSONRenderer',
    ],
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
    ],
}

It's also recommended to set JSONRenderer as the DEFAULT_RENDERER_CLASSES to disable DRF navigation and that fancy display.

When creating the session and whoami views, use APIView imported from rest_framework and explicitly set the authentication_classes and permission_classes:

# backend/api/views.py

from django.http import JsonResponse
from rest_framework.authentication import SessionAuthentication, BasicAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView


class SessionView(APIView):
    authentication_classes = [SessionAuthentication, BasicAuthentication]
    permission_classes = [IsAuthenticated]

    @staticmethod
    def get(request, format=None):
        return JsonResponse({'isAuthenticated': True})


class WhoAmIView(APIView):
    authentication_classes = [SessionAuthentication, BasicAuthentication]
    permission_classes = [IsAuthenticated]

    @staticmethod
    def get(request, format=None):
        return JsonResponse({'username': request.user.username})

And when registering the URLs, register them like so:

# backend/api/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('csrf/', views.get_csrf, name='api-csrf'),
    path('login/', views.login_view, name='api-login'),
    path('logout/', views.logout_view, name='api-logout'),
    path('session/', views.SessionView.as_view(), name='api-session'),  # new
    path('whoami/', views.WhoAmIView.as_view(), name='api-whoami'),  # new
]

Grab the full code for this approach from GitHub: django_react_drf_same_origin.

Frontend served separately (cross domain)

With this approach, we'll build the frontend and serve it up separately from the Django app on a different domain. We'll have to relax the security a bit by allowing cross-domain requests from the frontend with django-cors-headers.

Start off by creating a project directory:

$ mkdir django_react_cross_origin && cd django_react_cross_origin

Backend

First, create a new directory called "backend" for the Django project:

$ mkdir backend && cd backend

Next, create and activate a new virtual environment, install Django, and create a new Django project:

$ python3.9 -m venv env
$ source env/bin/activate

(env)$ pip install django==3.1.4
(env)$ django-admin.py startproject djangocookieauth .

After that, create a new app called api:

(env)$ python manage.py startapp api

Register the app in backend/djangocookieauth/settings.py under INSTALLED_APPS:

# backend/djangocookieauth/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api.apps.ApiConfig', # new
]

Our app is going to have the following API endpoints:

  1. /api/get_csrf/ will generate a CSRF token and return it as JSON
  2. /api/login/ allows the user to log in by providing their username and password
  3. /api/logout/ logs the user out
  4. /api/session/ checks whether a session exists
  5. /api/whoami/ fetches user data for an authenticated user

For the views, grab the full code here and add it to the backend/api/views.py file.

Add a urls.py file to "api" and define the following URLs:

# backend/api/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('csrf/', views.get_csrf, name='api-csrf'),
    path('login/', views.login_view, name='api-login'),
    path('logout/', views.logout_view, name='api-logout'),
    path('session/', views.session_view, name='api-session'),
    path('whoami/', views.whoami_view, name='api-whoami'),
]

Now, let's register our app URLs to the base project:

# backend/djangocookieauth/urls.py

from django.contrib import admin
from django.shortcuts import render
from django.urls import path, include  # new import


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),  # new
]

Now we have to relax a few security settings in order for our requests to go through. Let's first set our cookie settings in backend/djangocookieauth/settings.py:

CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_HTTPONLY = True

# PROD ONLY
# CSRF_COOKIE_SECURE = True
# SESSION_COOKIE_SECURE = True

Notes:

  1. Setting CSRF_COOKIE_SAMESITE and SESSION_COOKIE_SAMESITE to Lax allows us to send CSRF cookies in external requests.
  2. Enabling CSRF_COOKIE_HTTPONLY and SESSION_COOKIE_HTTPONLY blocks client-side JavaScript from accessing the CSRF and session cookies.

If you're in production, you should serve your website over HTTPS and enable CSRF_COOKIE_SECURE and SESSION_COOKIE_SECURE, which will only allow the cookies to be sent over HTTPS.

In order to allow cross-origin cookie saving we also need to change some of the CORS settings. We'll use django-cors-headers for that. Let's start off by installing it using the following command:

(env)$ pip install django-cors-headers==3.5.0

Add it to your installed apps and add a new middleware class:

# backend/djangocookieauth/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api.apps.ApiConfig',
    'corsheaders',  # new
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',  # new
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

Configure CORS:

# backend/djangocookieauth/settings.py

CORS_ALLOWED_ORIGINS = [
    'http://localhost:3000',
    'http://127.0.0.1:3000',
]
CORS_EXPOSE_HEADERS = ['Content-Type', 'X-CSRFToken']
CORS_ALLOW_CREDENTIALS = True

Notes:

  1. We set the allowed origins in the CORS_ALLOWED_ORIGINS list. It's worth noting that for testing purposes, instead of using the CORS_ALLOWED_ORIGINS setting, you could instead set CORS_ALLOW_ALL_ORIGIN to True in order to allow any origin to make requests. Do not use this in production, though.
  2. CORS_EXPOSE_HEADERS is a list of HTTP headers that are exposed to the browser.
  3. Setting CORS_ALLOW_CREDENTIALS to True allows cookies to be sent along with cross-origin requests.

Code for our backend is now more or less done. Let's run the migrate command and create a superuser for testing in the future:

(env)$ python manage.py migrate
(env)$ python manage.py createsuperuser

Frontend

Before you start working on the frontend, make sure that you have Node.js and npm (or Yarn) installed.

We'll use Create React App to scaffold out a new React project.

From the project root, run:

$ npx create-react-app frontend
$ cd frontend
$ npm start

This will start our app on port 3000. Visit http://localhost:3000 to ensure it works:

React default page

You can simplify the frontend, by removing all files and folders except:

├── README.md
├── node_modules
├── package-lock.json
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── manifest.json
│   └── robots.txt
└── src
    ├── App.js
    ├── index.css
    └── index.js

Next, let's add Bootstrap frontend/public/index.html:

<!-- frontend/public/index.html -->

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <title>React App</title>
    <!-- new -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" crossorigin="anonymous">
    <!-- end of new -->
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>

Grab the full code here for the App component and add it to the frontend/src/App.js file.

This is just a simple frontend application with a form, which is handled by React state. On page load, compontentDidMount() is called which performs two API calls:

  1. First, it checks if the user is authenticated by calling /api/session/ and sets isAuthenticated to either true or false.
  2. If the user is not authenticated it fetches the CSRF token from /api/csrf/ and saves it to the state.

Note that with every request we used credentials: include. This is required because we want the browser to pass cookies with each HTTP request even if the URL is not the same origin as the calling script. Keep in mind that we changed some of the CORS settings in the backend to allow this.

Update frontend/src/index.js:

// frontend/src/index.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App.js";

import "./index.css";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

Running the application

Move to your backend folder and run Django using:

(env)$ python manage.py runserver

Your backend should be accessible at: http://localhost:8000.

Open a new terminal window, navigate to the "frontend" folder and start React with npm:

$ npm start

You should should be able to access your app at http://localhost:3000.

Test your application by logging in with the superuser you've created before.

Grab the full code for this approach from GitHub: django_react_same_origin

Conclusion

This article detailed how to set up session-based authentication for Single-Page Applications with Django and React. Whether you use session cookies or tokens, it's good to use cookies for authentication when the client is a browser. While it's preferable to serve up both apps from the same domain, you can serve them on different domains by relaxing the cross-domain security settings.

We looked at four different approaches for combining Django with a frontend framework for handling session-based auth:

Approach Frontend Backend
Frontend served from Django Grab the CSRF token using universal-cookies and use credentials: "same-origin" in the requests. Set CSRF_COOKIE_SAMESITE, SESSION_COOKIE_SAMESITE to "Strict". Enable SESSION_COOKIE_HTTPONLY and disable CSRF_COOKIE_HTTPONLY.
Frontend served separately (same domain) Obtain CSRF token and use credentials: "same-origin" in the fetch request. Add a route handler for generating the CSRF token that gets set in the response headers. Set SESSION_COOKIE_HTTPONLY, CSRF_COOKIE_HTTPONLY to True and SESSION_COOKIE_SAMESITE, CSRF_COOKIE_SAMESITE to "Strict".
Frontend served separately with DRF (same domain) Obtain CSRF token and use credentials: "same-origin" in the fetch request. Add a route handler for generating the CSRF token that gets set in the response headers. Set SESSION_COOKIE_HTTPONLY, CSRF_COOKIE_HTTPONLY to True and SESSION_COOKIE_SAMESITE, CSRF_COOKIE_SAMESITE to "Strict".
Frontend served separately (cross-origin) Obtain CSRF token and use credentials: "include" in the fetch request. Enable CORS and add a route handler for generating the CSRF token that gets set in the response headers. Set SESSION_COOKIE_HTTPONLY, CSRF_COOKIE_HTTPONLY to True and SESSION_COOKIE_SAMESITE, CSRF_COOKIE_SAMESITE to "Lax". Add the django-cors-headers package and configure the CORS_ALLOWED_ORIGINS, CORS_EXPOSE_HEADERS, and CORS_ALLOW_CREDENTIALS settings.

Grab the code from django-spa-cookie-auth repository.

Nik Tomazic

Nik Tomazic

Nik is a software developer from Slovenia. He's interested in object-oriented programming and web development. He likes learning new things and accepting new challenges. When he's not coding, Nik's either swimming or watching movies.

Share this tutorial

Featured Course

Test-Driven Development with Django, Django REST Framework, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a RESTful API powered by Python, Django, and Django REST Framework.

SaaS Pegasus advertisement - https://www.saaspegasus.com/?utm_source=testdriven&utm_medium=banner&utm_campaign=ad1
Featured Course

Test-Driven Development with Django, Django REST Framework, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a RESTful API powered by Python, Django, and Django REST Framework.