Using Django's session auth with your SPA

Published:

I keep hearing people saying they use Token auth because they think Django's default session-based auth won't work with their Vue/React/whatever Single Page Application (SPA).

This has always baffled me, beyond assuming people are cargo culting something.

Also I've recently had interactions with someone I deeply respect, who also seemed to feel Django's docs on handling authentication with a SPA were lacking.

So here's my notes on what it takes.

Overview

Django's sessions work by putting a session ID token into a cookie. Since web browsers send cookies automatically, it takes no effort on the part of your SPA to send this with every request.

If you're using fetch you will need to set credentials to at least same-origin.

Django's session-based auth works by storing the user ID and the auth backend that found them in the session.

So long as the user agent (e.g. your browser) sends the cookie, it will "just work".

Problems

The only problem I've ever had with using Django's login machinery from a SPA is handling CSRF.

I've seen some people choose to disable it, and this seems backwards to me. Why disable security around auth, of all things?

Instead, I provide an API endpoint for requesting the current user, and decorate it with ensure_csrf_cookie.

Now when your SPA starts up, it can query for the current User, to check if they're logged in. In doing so, it will also cause a fresh CSRF cookie to be issued.

Remember to ensure your POST requests pass along the CSRF token as described in the CSRF docs.

The Endpoint

So first, let's provide a Class-Based view that will let your SPA check the current user's details with a GET:

from django import http
from django.views.generic import View

class UserView(View):
    def get(self, request):
        if not request.user.is_authenticated:
          return http.HttpResponseForbidden()
        return http.JsonResponse({
          "id": request.user.pk,
          "username": request.user.get_username(),
        })

Relevant docs:

Class-Based Views

JsonResponse

Next, we'll add in the ensure_csrf_cookie decorator:

from django import http
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import View

@method_decorator(ensure_csrf_cookie, 'dispatch')
class UserView(View):
    def get(self, request):
        if not request.user.is_authenticated:
          return http.HttpResponseForbidden()
        return http.JsonResponse({
          "id": request.user.pk,
          "username": request.user.get_username(),
        })

Relevant docs:

method_decorator

ensure_csrf_cookie

The client side code might look like:

const user = await fetch('/user/', { credentials: 'same-origin' }).then(resp => resp.json())

This is enough for our SPA to query the current user, or detect they're not logged in. Next we'll let them log in!

Logging in

As you normally would, capture the user identifier and password, and POST them to the view. All the heavy lifting here is done by Django's existing forms and login machinery.

from django import http
from django.contrib.auth import login
from django.contrib.auth.forms import AuthenticationForm
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import View

@method_decorator(ensure_csrf_cookie, 'dispatch')
class UserView(View):
    def get(self, request):
        if not request.user.is_authenticated:
          return http.HttpResponseForbidden()
        return http.JsonResponse({
          "id": request.user.pk,
          "username": request.user.get_username(),
        })

    def post(self, request):
        form = AuthenticationForm(request, request.POST)
        if form.is_valid():
          login(request, form.get_user())
          return self.get(request)

        return http.JsonResponse(form.errors.get_json_data(), status=400)

Relevant docs:

AuthenticationForm

How to log a user in

form.errors.get_json_data()

Again, some basic client side code to post to this might be:

const user;
document.querySelector('#login-form').addEventListener('submit', async ev => {
  ev.preventDefault();
  const data = new FormData(ev.target);
  const resp = await fetch('/user/', {
    method: 'POST',
    body: data,
    credentials: 'same-origin'
    headers: { 'X-CSRFToken': csrf_token },
  })
  if (resp.ok) {
    user = await resp.json();
  } else {
    errors = await resp.json();
  }
})

See here for how to parse your cookies.

Alternatively, if you don't have a <form> element handy, you can programatically build a FormData:

const data = new FormData()
data.append('username', username);
data.append('password', password);

See here for more details on the FormData object.

Logging out

It always seemed logical to me logging out is like deleting your login, so why not use the HTTP DELETE verb?

from django import http
from django.contrib.auth import login, logout
from django.contrib.auth.forms import AuthenticationForm
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import View

@method_decorator(ensure_csrf_cookie, 'dispatch')
class UserView(View):
    def get(self, request):
        if not request.user.is_authenticated:
          return http.HttpResponseForbidden()
        return http.JsonResponse({
          "id": request.user.pk,
          "username": request.user.get_username(),
        })

    def post(self, request):
        form = AuthenticationForm(request, request.POST)
        if form.is_valid():
          login(request, form.get_user())
          return self.get(request)

        return http.JsonResponse(form.errors.get_json_data(), status=400)

    def delete(self, request):
        logout(request)

        return http.HttpResponse(status=205)  # Reset Content

Conclusion

I hope this shows that using Django's default, built-in session auth system can be easy with you SPA app. If you have any problems or feedback, feel free to comment and let me know, and I'll update this post!

Thanks

Many thanks to Bjorn Dissing who prompted me to finally write this post, and helped me field test it! They've even created a sample implementation and published it!.