Django Session-based Auth for Single Page Apps
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.
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 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.
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:
- Serve up the framework via Django templates
- Serve up the framework separately from Django on the same domain
- Serve up the framework separately from Django with Django REST Framework on the same domain
- Serve up the framework separately from Django on a different domain
Again, feel free to swap out React for the frontend of your choice -- e.g., 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.11 -m venv env
$ source env/bin/activate
(env)$ pip install django==4.2.3
(env)$ django-admin 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:
/api/login/
allows the user to log in by providing their username and password/api/logout/
logs the user out/api/session/
checks whether a session exists/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:
- Setting
CSRF_COOKIE_SAMESITE
andSESSION_COOKIE_SAMESITE
toTrue
prevents cookies and CSRF tokens from being sent from any external requests. - Setting
CSRF_COOKIE_HTTPONLY
andSESSION_COOKIE_HTTPONLY
toTrue
blocks client-side JavaScript from accessing the CSRF and session cookies. We setCSRF_COOKIE_HTTPONLY
toFalse
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
andSESSION_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 Vite to scaffold out a new React project:
$ npm create vite@4.4 frontend
Select React as the framework with JavaScript as the variant:
✔ Select a framework: › React
✔ Select a variant: › JavaScript
Then, install the dependencies and run the development server:
$ cd frontend
$ npm install
$ npm run dev
This will start our app on port 5173. Visit http://localhost:5173/ to ensure it works.
To simplify things, remove the following CSS files:
- frontend/src/App.css
- frontend/src/index.css
Next, let's add Bootstrap to frontend/index.html:
<!-- frontend/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
<!-- new -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<!-- end of new -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</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@4.0.4
Grab the full code here for the App
component and add it to the frontend/src/App.jsx 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/main.jsx:
// frontend/src/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
Serving React
Since we'll be serving up static files from the /static/
URL, add the public base config to frontend/vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: '/static/' // new
})
Then, build the frontend application:
$ npm run build
This command will generate the "dist" 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', 'dist'), # 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, 'dist/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 http://localhost:8000/. Your React app is now served via Django templates.
On load, the CSRF cookie is set, which is used in subsequent XHR requests. If the user enters the correct username and password, it authenticates them and saves the sessionid
cookie to their browser.
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:
Back to Top