Full Tutorial: User Authentication with Nuxt 3 and Django

Recently, I had to set up user authentication for FLYP, a new project I am working on. Flyp is using Djanago with Django Rest Framework (DRF) in the backend, and the Nuxt 3 Vue framework on the frontend.

Since I have built most of my projects on top of React / NextJS in the past, I struggled quite a bit with properly implementing user authentication on this new tech stack.

I decided to do a quick write-up of the solution that will hopefully help some people (including myself) for future projects.

Backend Project Setup

Skip this section if you already have your Django backend set up.

I recommend you use a virtual environment, but I will skip this step. You can find the instructions to set up a virtual python environment under this link: Click here

Install Django and dependencies

Start by installing Django and Django Rest Framework

pip install django djangorestframework

We also need some additional dependencies:

  • SimpleJWT: We'll be using Json Web Tokens (JWT) for our authentication, and SimpleJWT is a great library that integrates well with DRF
  • Django Cors Headers: Since we are working with a decoupled frontend / backend architecture, we need this library to help us deal with CORS issues.
pip install djangorestframework-simplejwt django-cors-headers

Start Django Project

Replace auth-example with your project name

django-admin startproject auth-example

Install users app

This will create a new users app in your project, which will contain all of our user-related code.

django-admin startapp users

Django Settings Configuration

First of all, make sure that you add all of the required apps to the INSTALLED_APPS list

# settings.py

INSTALLED_APPS = [
    "users.apps.UsersConfig",
    # ...
    "corsheaders",
]

Now configure CORS to allow requests coming from your frontend. I'm using localhost:3000 in development, but you might need to adjust this to your preference.

# settings.py

ALLOWED_HOSTS = ("*",) # not recommended for production

CORS_ORIGIN_WHITELIST = [
    "http://localhost:3000",
]

CSRF_TRUSTED_ORIGINS = [
    "http://localhost:3000",
]

Finally, specify how you would like DRF to handle authentication and add settings for your JWT tokens.

Depending on the nature of your app, you might want to set the default permissinon on DEFAULT_PERMISSION_CLASSES to IsAuthenticated. For my case, AllowAny works well, since most requests don't require user authentication.

Also, make sure to replace the default authentication class with the JWTAuthentication class from the SimpleJWT library.

You might want to adapt the SIMPLE_JWT settings to your preferences. This page explains what each option does.

# settings.py

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",),
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ),
}

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=50),
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": False,
    "UPDATE_LAST_LOGIN": False,
    "ALGORITHM": "HS256",
    "SIGNING_KEY": SECRET_KEY,
    "USER_ID_FIELD": "id",
}

Custom User Model

It's usually a good idea to create a custom user model in Django, since it can be a pain to work with the default one. Luckily, this is pretty straightforward. The code below assumes that you want users to authenticate using their email address instead of a username.

# users/models.py

from django.db import models
from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    email = models.EmailField(unique=True)
    username = None
    is_active = models.BooleanField(default=True) # set to false if you want users to verify their email first
    first_name = models.CharField(max_length=50, blank=True, null=True)
    last_name = models.CharField(max_length=50, blank=True, null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    # Add additional fields according to your requirements

    # birthday = models.DateField(blank=True, null=True)
    # country = models.CharField(max_length=50, blank=True, null=True)

    objects = UserManager()

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []

    def __str__(self):
        return self.email

Next, define a custom user manager in the same file. I have been using the below code for all of my Django projects - unless you have any special requirements, you can probably re-use it as it is.

# users/models.py

from django.contrib.auth.base_user import BaseUserManager

class UserManager(BaseUserManager):
    use_in_migrations = True

    def _create_user(self, email, password, **extra_fields):
        if not email:
            raise ValueError("Users require an email field")
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, email, password=None, **extra_fields):
        extra_fields.setdefault("is_staff", False)
        extra_fields.setdefault("is_superuser", False)
        return self._create_user(email, password, **extra_fields)

    def create_superuser(self, email, password, **extra_fields):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)

        if extra_fields.get("is_staff") is not True:
            raise ValueError("Superuser must have is_staff=True.")
        if extra_fields.get("is_superuser") is not True:
            raise ValueError("Superuser must have is_superuser=True.")

        return self._create_user(email, password, **extra_fields)

User Registration

Now lets quickly implement the logic needed to register new users. To achieve this, we need a serializer and an endpoint that receives the data from the frontend.

My registration form only asks for email and password (+ password confirmation). If you want to capture more information from your users during signup, you would need to add the additional fields to the serializer and validate them based on your requirements.

The code below makes sure that the email address is unique, and that the two passwords are matching. Additionally, it also runs some of Django's built in password validation, so it doesn't allow passwords that are too common, etc.. If everything looks good, the serializer takes care of saving the user to the database.

# users/serializers.py

from rest_framework import serializers
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from rest_framework.validators import UniqueValidator
from rest_framework.exceptions import ValidationError

class RegisterSerializer(serializers.ModelSerializer):
    email = serializers.EmailField(
        required=True,
        validators=[UniqueValidator(queryset=get_user_model().objects.all())],
    )
    password = serializers.CharField(
        write_only=True, required=True, validators=[validate_password]
    )
    password2 = serializers.CharField(write_only=True, required=True)

    class Meta:
        model = get_user_model()
        fields = ("email", "password", "password2")
        extra_kwargs = {
            "password": {"write_only": True, "min_length": 8},
        }

    def to_representation(self, instance):
        data = super().to_representation(instance)
        if hasattr(self, "errors"):
            data["errors"] = self.errors
        return data

    def validate(self, attrs):
        if attrs["password"] != attrs["password2"]:
            raise serializers.ValidationError(
                {"password": "Password fields didn't match."}
            )

        return attrs

    def create(self, validated_data):
        user = self.Meta.model.objects.create(
            email=validated_data["email"],
        )

        user.set_password(validated_data["password"])
        user.save()

        return user

Next, set up an endpoint that your frontend can call when the user clicks the Register button on the registration form. You might want to add some custom error handling here, but I'll keep it short.

# users/api.py

from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from django.contrib.auth import get_user_model
from users.serializers import RegisterSerializer

class RegisterView(generics.CreateAPIView):
    User = get_user_model()
    queryset = User.objects.all()
    permission_classes = (AllowAny,) #  necessary if your default permission class is set to IsAuthenticated
    serializer_class = RegisterSerializer

    def perform_create(self, serializer):
        user = serializer.save()

        # perform additional actions such as sending a welcome email

        return Response(user, status=status.HTTP_200_OK)

Alright, most of the work is done. The last thing we need to do is registering the endpoint in our urls.py file.

# users/urls.py

from django.urls import path
from .api import RegisterView

urlpatterns = [
    path("register/", RegisterView.as_view(), name="register"),
]

Also, make sure that the paths defined in your users app are recognized by your Django project. To do that, open the urls.py file located in your main app and make sure to include the urls like this:

# project/urls.py

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

urlpatterns = [
    # ...
    path("api/users/", include("users.urls")),
]

Now, you can test if your user registration works. The easiest way to do that is by using Postman or any similar API client to send a test request to your endpoint.

curl  -X POST \
  'http://127.0.0.1:8000/api/users/register/' \
  --header 'Accept: */*' \
  --header 'User-Agent: Thunder Client (https://www.thunderclient.com)' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "email": "test@email.com",
  "password": "secretPassword00",
  "password2": "secretPassword00",
}'

User Login

Setting up our endpoints for user login is pretty straight forward, since we can just use the views exposed by the SimpleJWT library. Back in your users/urls.py file, add the following paths:

from django.urls import path
from .api import RegisterView
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView


urlpatterns = [
    path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
    path("register/", RegisterView.as_view(), name="register"),
]

We now have two new paths:

  • token/: This is the endpoint we'll call to authenticate users. It returns a dictionary containing access token and refresh token. The decoded token holds information about the user and the token expiry.
  • token/refresh/: Our frontend is going to send the refresh token to this endpoint whenever our access token has expired. Like the other endpoint, it also returns a dictionary containing a new access and the refresh token.

Again, you can test these endpoints with an API client but if you have installed the package correctly, there should be much room for error up to this point. Next, we'll get to the part of setting up the frontend.

Frontend Project Setup

Skip this step if you already have a Nuxt3 project set up.

Initialize Nuxt Project

npx nuxi@latest init <project-name>

cd into your project folder and run npm run dev to verify that the project has been set up correctly.

Next, we'll install a few additional dependencies that we'll use:

  • pinia: For storing authentication state
  • jwt-decode: For decoding JWT Tokens
  • dayjs: For checking if tokens are expired (you can use any alternative library if you prefer)
npm install pinia @pinia/nuxt jwt-decode dayjs

Now open your nuxt.config.js file and make sure to add the following:

// nuxt.config.js

export default defineNuxtConfig({
  // ...
  modules: ['@pinia/nuxt'],
  runtimeConfig: {
    public: {
      apiUrl: process.env.API_URL,
    },
  },
})

To make this work, create a .env file in your project root and add API_URL=http://127.0.0.1:800/api/ or enter the url directly into your nuxt.config.js file instead of using an environment variable.

To be able to use the Nuxt pages folder as our routing system, open your app.vue file and replace it's content with the following:

// app.vue

<template>
  <NuxtPage />
</template>

Then, go ahead and create the pages folder, along with two files: login.vue and register.vue.

mkdir pages
touch pages/login.vue pages/register.vue

Login Page

For the sake of keeping this tutorial as simple as possible, I'll just add the most basic forms into the pages. Later, we'll implement the click handler functions. Make sure you use @submit.prevent on the forms in order to prevent the default form submission behaviour.

// pages/login.vue

<template>
  <form @submit.prevent="handleSubmit">
    <input type="email" v-model="credentials.email" placeholder="Email" />
    <input
      type="password"
      v-model="credentials.password"
      placeholder="Password"
    />
    <button type="submit">Login</button>
  </form>
</template>
<script setup>
const credentials = reactive({
  email: "",
  password: "",
});

const handleSubmit = () => {
  console.log("logging in...");
};
</script>

Register Page

// pages/register.vue

<template>
  <form @submit.prevent="handleSubmit">
    <input type="email" v-model="credentials.email" placeholder="Email" />
    <input
      type="password"
      v-model="credentials.password"
      placeholder="Password"
    />
    <input
      type="password"
      v-model="credentials.password2"
      placeholder="Confirm Password"
    />
    <button type="submit">Reigster</button>
  </form>
</template>
<script setup>
const credentials = reactive({
  email: "",
  password: "",
  password2: "",
});

const handleSubmit = () => {
  console.log("registering...");
};
</script>

We'll encapsulate all of our authentication logic in a separate file, so that we can reuse it in other places if necessary. Create another folder store, along with an auth.js file in it.

mkdir store
touch store/auth.js

Inside of this file, we'll define some state variables and a bunch of actions that will help us authenticate the user, refresh tokens, and make authenticated requests. To begin, lets just define the skeleton:

// store/auth.js

import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    isAuthenticated: false,
    authTokens: null,
  }),
  actions: {},
})

User Registration

Lets start by defining an action for registering a user. We're already capturing the credentials through the reactive object in the register page. On submit, we pass them to the register function, which we need to define inside of the authStore. The register action calls our register endpoint. If there are any validation issues, the serializers will return the appropriate error message. In this example, we just extract the first error message and pass to the error handler.

Note that we're using the apiUrl previously defined in the Nuxt Config.

// store/auth.js

import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    isAuthenticated: false,
    authTokens: null,
  }),
  actions: {
    async register(credentials) {
      const config = useRuntimeConfig()
      try {
        const response = await fetch(`${config.public.apiUrl}users/register/`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(credentials),
        })
        const data = await response.json()

        if (response.status !== 201) {
          // Handle validation errors specifically
          if (typeof data === 'object' && data !== null) {
            const errors = Object.values(data)
            if (errors.length > 0 && errors[0].length > 0) {
              // Extract the first error message from the first property with an error
              throw new Error(errors[0][0])
            }
          }

          // Fallback error message if the structure wasn't as expected
          throw new Error('Registration failed due to an unexpected error.')
        }

        return data // On success, return the response data
      } catch (error) {
        return {
          error: error.message || 'An error occurred during registration.',
        }
      }
    },
  },
})

Adjust your handleSubmit function like this - note that I also added refs for loading and error state, which you can use to display information to your user. In this example, we're redirecting the user to the login page after successful registration, but you could also automatically log the user in if you wish to do so.

// pages/register.js

<script setup>
const credentials = reactive({
  email: "",
  password: "",
  password2: "",
});

const loading = ref(false);
const error = ref(null);

const handleSubmit = async () => {
  loading.value = true;
  error.value = null;

  try {
    const response = await register(credentials);
    if (response.error) {
      throw new Error(response.error);
    }
    window.location.href = "/login";
  } catch (err) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
};
</script>

User Login

To login our users, we need to define another action in the authStore. This function takes the user credentials as an input and passes them to our token/ endpoint, which is responsible for authenticating the user and returning the corresponding auth tokens.

In this tutorial, we're saving the auth tokens to localStorage. This has some limitations as the tokens are only available on the client, but I haven't gotten around to implementing a more robust way of storing the tokens, and this approach has been working well for me.

Once the user has been authenticated, we also update the store variables to reflect that. Don't forget to import jwtDecode at the top of the file, which is necessary to decode the JWT info.

// auth/store.js
import { defineStore } from 'pinia'
import { jwtDecode } from 'jwt-decode'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    isAuthenticated: false,
    authTokens: null,
  }),
  actions: {
    // ...
    async login(credentials) {
      try {
        const config = useRuntimeConfig()
        const response = await $fetch(`${config.public.apiUrl}users/token/`, {
          method: 'POST',
          body: JSON.stringify(credentials),
        })

        const authTokens = response

        const user = jwtDecode(authTokens.access)
        this.user = user
        this.isAuthenticated = true
        this.authTokens = authTokens

        localStorage.setItem('authTokens', JSON.stringify(authTokens))
      } catch (error) {
        console.error('Login error:', error)
        throw error
      }
    },
  },
})

In the login page, make the following adjustments to handle the form submit. In this example, we'll just redirect the user to the main page after a successful login.

// pages/login.vue

<script setup>
import { useRouter } from "vue-router";
import { useAuthStore } from "~/store/auth.js";

const authStore = useAuthStore();

const { login } = authStore;

const error = ref(null);
const router = useRouter();

const credentials = reactive({
  email: "",
  password: "",
});

const handleSubmit = async () => {
  try {
    await login(credentials);
    router.push("/");
  } catch (err) {
    error.value = err.message;
  }
};
</script>

If you want to automatically redirect already authenticated users from the login page to another page, you can also do so. However, since we can only access the authentication state from the client, your users might see a little flicker of the login page before being redirected, so it's better to prevent navigation to /login through your UI (hide login link when user is authenticated).

Add this code to your script tag in the pages/login.vue file to redirect authenticated users (you can add the same on your register view):

// pages/login.js

import { useAuthStore } from '~/store/auth.js'
import { storeToRefs } from 'pinia'
import { watch } from 'vue'

const authStore = useAuthStore()
const { isAuthenticated } = storeToRefs(authStore)

// all of the previous code

watch(
  isAuthenticated,
  (newValue) => {
    if (newValue) {
      router.push('/')
    }
  },
  { immediate: true },
)

Automatic Authentication for Returning Users

Once a user has logged into your app, you probably want to persist the authentication status, so your users don't need to log in every single time they visit your app. To do that, we can let our app check if the user is authenticated whenever the page loads.

This can be achieved by implementing a checkAuth action in the auth store. This action will retrieve auth tokens from localStorage and update the store variables accordingly.

Again, note that this can only happen on the client, and not server side.

// auth/store.js
import { defineStore } from 'pinia'
import { jwtDecode } from 'jwt-decode'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    isAuthenticated: false,
    authTokens: null,
  }),
  actions: {
    // ...
    checkAuth() {
      this.authTokens = JSON.parse(localStorage.getItem('authTokens'))
      if (this.authTokens) {
        this.isAuthenticated = true
        this.user = jwtDecode(this.authTokens.access)
      }
    },
  },
})

Then call this function inside of a high level component's setup script tag, like a layout or a header. I like to add this in the layout component, and add a default loading state. Once I know the auth status, I set loading to false and display the content.

// layouts/default.vue
// inside of <script setup>

import { useAuthStore } from '~/store/auth'
import { onMounted } from 'vue'

const loading = ref(true)

onMounted(() => {
  loading.value = false
  const authStore = useAuthStore()
  authStore.checkAuth()
  if (authStore.isAuthenticated && !authStore.user) {
    authStore.fetchUser()
  }
})

Making Authenticated Requests

In order to make requests to API endpoints that are reserved for authenticated users, we need to create wrapper functions around the standard fetch functions. These wrapper functions will take care of getting the access token and including it into the request headers.

Here's how I like to set this up. I create a generic authedRequest function, which injects the access token into the request header. I'll explain how the access token is handled below.

Additionally, I create separate functions for each type of request (GET, POST, PUT, DELETE). These functions set the request method and pass data to the authedRequest function if necessary.

// auth/store.js
import { defineStore } from 'pinia'
import { jwtDecode } from 'jwt-decode'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    isAuthenticated: false,
    authTokens: null,
  }),
  actions: {
    // ...
    async authedRequest(url, originalConfig = {}) {
      const config = { ...originalConfig }
      // Explained below
      const accessToken = await this.retrieveValidToken()

      if (!accessToken) {
        return Promise.reject('No auth token found')
      }

      if (!config.headers) {
        config.headers = {}
      }
      config.headers['Authorization'] = `Bearer ${accessToken}`

      if (config.data) {
        config.body = config.data
        delete config.data
      }

      try {
        return await fetch(url, config)
      } catch (error) {
        console.error('Failed to make authenticated request:', error)
        return Promise.reject(error)
      }
    },
    async makeRequest(method, url, data = {}, config = {}) {
      config.method = method
      if (data && Object.keys(data).length > 0) {
        config.data = data
      }
      return await this.authedRequest(url, config)
    },
    async authedPost(url, data, config = {}) {
      return this.makeRequest('POST', url, data, config)
    },
    async authedPut(url, data, config = {}) {
      return this.makeRequest('PUT', url, data, config)
    },
    async authedGet(url, config = {}) {
      return this.makeRequest('GET', url, null, config)
    },
    async authedDelete(url, config = {}) {
      return this.makeRequest('DELETE', url, null, config)
    },
  },
})

The retrieveValidToken function is important, as it makes sure that the authenticated request includes a valid accessToken.

Inside of this function, we first check for auth tokens in localStorage and then check if the access token is still valid. If it is not, we make an intermediary API call to our token/refresh/ endpoint, and use the response to update the auth tokens on the client. Once we have a valid token, we pass it back to the authedRequest function, where it was called from.

// auth/store.js
import { defineStore } from 'pinia'
import { jwtDecode } from 'jwt-decode'
import dayjs from 'dayjs'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    isAuthenticated: false,
    authTokens: null,
  }),
  actions: {
    // ...
    async retrieveValidToken() {
      this.authTokens = JSON.parse(localStorage.getItem('authTokens'))
      if (!this.authTokens) {
        return null
      }

      const user = jwtDecode(this.authTokens.access)
      // Set isExpired to true if token expires in less than a minute from now
      const isExpired = dayjs.unix(user.exp).diff(dayjs(), 'minute') < 1

      if (isExpired) {
        try {
          const newTokens = await this.refreshToken()
          if (newTokens) {
            localStorage.setItem('authTokens', JSON.stringify(newTokens))
            this.authTokens = newTokens
            this.user = jwtDecode(newTokens.access)

            return newTokens.access
          }
        } catch (err) {
          console.error('Error refreshing token', err)
          return null
        }
      }

      return this.authTokens.access
    },
    async refreshToken() {
      const rToken = this.authTokens?.refresh
      if (!rToken) {
        console.error('No refresh token available')
        return null
      }

      try {
        const config = useRuntimeConfig()
        const response = await $fetch(
          `${config.public.apiUrl}users/token/refresh/`,
          {
            method: 'POST',
            body: JSON.stringify({ refresh: rToken }),
          },
        )
        return response
      } catch (error) {
        console.error('Failed to refresh token:', error)
        this.logout()
        return null
      }
    },
  },
})

If you have all of this set up, your app should be ready to make authenticated requests on behalf of your users.

Logout User

Finally, you can set up a logout action, which basically just removes the auth tokens from local storage and updates the store variables:

// auth/store.js
import { defineStore } from 'pinia'
import { jwtDecode } from 'jwt-decode'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    isAuthenticated: false,
    authTokens: null,
  }),
  actions: {
    // ...
    logout() {
      this.user = null
      this.isAuthenticated = false
      localStorage.removeItem('authTokens')
    },
  },
})

Final Word

If you made it all this way, congratulations. I hope this tutorial was useful for you. If you have any questions, feel free to reach out to me at pk@pkundr.com.

I also offer services in the areas of Product Management and App development. If you need help with a project, I would be happy to discuss it with you.

Philipp