Building a Custom Authentication System with JWT in Nuxt 3

Learn how to implement a robust JWT authentication system in Nuxt 3. This comprehensive guide covers project setup, login/signup functionality, protected routes, and state management with Pinia.

Charles Allotey
Nuxt 3

Mastering Nuxt 3 course is here!

Get notified when we release new tutorials, lessons, and other expert Nuxt content.

Click here to view the Nuxt 3 course

In this guide, we'll walk through the process of building a custom authentication system using JSON Web Tokens (JWT) in a Nuxt 3 application. JWT provides a secure and efficient way to handle user authentication, making it an excellent choice for modern web applications. We'll cover everything from setting up the project to implementing the authentication logic, ensuring you have a robust and scalable solution for your Nuxt 3 project.

Before we dive into the implementation, let's briefly discuss JSON Web Tokens (JWT):

JWT is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.

Key features of JWT:

  • Stateless: The server doesn't need to store session information, as all necessary data is contained within the token itself.
  • Secure: JWTs are signed, ensuring that the claims cannot be altered after the token is issued.
  • Compact: JWTs are small and can be sent through URL, POST parameter, or inside an HTTP header.
  • Self-contained: The token contains all the necessary information about the user, eliminating the need for multiple database queries.

In our authentication system, we'll use JWTs to securely manage user sessions and protect our API routes. This approach will provide a scalable and efficient solution for handling user authentication in our Nuxt 3 application.

Project Setup

First, create a new Nuxt 3 project and add the required dependencies:

npx nuxi init nuxt-auth-system
cd nuxt-auth-system
npx nuxi@latest module add pinia
npm install jsonwebtoken bcryptjs

We will be using Pinia as our state management solution, jsonwebtoken for generating and verifying JWTs, and bcryptjs for password hashing. These libraries will form the backbone of our authentication system.

App Setup

To set up our Nuxt 3 application with the authentication system we're building, we need to create the main app layout and configure our routes. This will tie together all the components we will be creating and provide a seamless user experience. Let's start by creating the app layout in the app.vue file:

<template>
  <div>
    <header class="p-4 bg-gray-100">
      <nav v-if="authStore.isAuthenticated">
        <button @click="authStore.logout">Logout</button>
      </nav>
      <nav v-else class="flex gap-4">
        <NuxtLink
          to="/login"
          class="text-blue-500 hover:text-blue-600"
        >
          Login
        </NuxtLink>
        <NuxtLink
          to="/signup"
          class="text-blue-500 hover:text-blue-600"
        >
          Sign Up
        </NuxtLink>
      </nav>
    </header>

    <NuxtPage />
  </div>
</template>

<script setup lang="ts">
const authStore = useAuthStore()

// Check authentication status on app mount
onMounted(() => {
  authStore.checkAuth()
})
</script>

Setting Up the Auth Store

Now, let's create the authentication store using Pinia. This store will manage the user's authentication state, handle login and logout actions, and provide methods to check the user's authentication status.

Create a new file called auth.ts in the stores directory of your Nuxt project and add the following code:

// ~/stores/auth.ts
import { defineStore } from 'pinia'

interface User {
  id: string
  email: string
  name: string
}

interface AuthState {
  user: User | null
  token: string | null
  isAuthenticated: boolean
  loading: boolean
}

export const useAuthStore = defineStore('auth', {
  state: (): AuthState => ({
    user: null,
    token: null,
    isAuthenticated: false,
    loading: true
  }),

  actions: {
    setUser(user: User) {
      this.user = user
      this.isAuthenticated = true
    },

    setToken(token: string) {
      this.token = token
      // Store token in localStorage for persistence
      localStorage.setItem('token', token)
    },

    clearAuth() {
      this.user = null
      this.token = null
      this.isAuthenticated = false
      localStorage.removeItem('token')
    },

    async login(email: string, password: string) {
      try {
        const response = await $fetch('/api/auth/login', {
          method: 'POST',
          body: { email, password }
        })

        this.setToken(response.token)
        this.setUser(response.user)
        return true
      } catch (error) {
        console.error('Login error:', error)
        return false
      }
    },

    async signup(name: string, email: string, password: string) {
        try {
          const response = await $fetch('/api/auth/signup', {
            method: 'POST',
            body: { name, email, password }
          })

          // Automatically log in the user after successful signup
          this.setToken(response.token)
          this.setUser(response.user)
          return { success: true }
        } catch (error: any) {
          return {
            success: false,
            error: error.data?.message || 'Signup failed'
          }
        }
      },

    async logout() {
      try {
        const token = localStorage.getItem('token')
        if (!token) return
        await $fetch('/api/auth/logout', {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${token}`
          }
        })
      } finally {
        this.clearAuth()
        await navigateTo('/')
      }
    },

    async checkAuth() {
      try {
        const token = localStorage.getItem('token')
        if (!token) return false

        const response = await $fetch('/api/auth/me', {
          headers: {
            Authorization: `Bearer ${token}`
          }
        })

        this.setToken(token)
        this.setUser(response.user)
        return true
      } catch {
        this.clearAuth()
        return false
      } finally {
        this.loading = false
      }
    }
  }
})

if (import.meta.hot) {
    import.meta.hot.accept(acceptHMRUpdate(useAuthStore, import.meta.hot))
  }

Creating Middleware

To protect routes and manage authentication flow in our Nuxt 3 application, we'll create two authentication middleware files. These middleware will verify the user's authentication status and handle redirects based on the routes they attempt to access.

The first file we'll create in our middleware directory is protected.js, which will verify if a user is authenticated when they try to access protected pages, such as the dashboard

// ~/middleware/protected.js

export default defineNuxtRouteMiddleware(async (to) => {
    if (import.meta.server) return
    const authStore =  useAuthStore()
    await authStore.checkAuth()

    // If user is not authenticated and trying to access a protected route
    if (!authStore.user) {
      return navigateTo('/login')
    }
  })

The guest.js middleware handles redirecting authenticated users away from auth pages (like login and signup). This prevents logged-in users from accessing these pages unnecessarily, complementing the protected middleware by managing the opposite scenario.

export default defineNuxtRouteMiddleware(async (to) => {
    if (import.meta.server) return
    const authStore =  useAuthStore()
    await authStore.checkAuth()

    // If user is authenticated and trying to access auth pages (login/register)
    if (authStore.user) {
      return navigateTo('/')
    }
  })

Setting Up API Routes

Now that we have set up our authentication store and middleware, we need to create the API routes to handle user authentication. These routes will be responsible for processing login requests, verifying user credentials, and generating JWT tokens.

Let's start by creating a login route. Create a new file called login.ts in the server/api/auth directory of your Nuxt project and add the following code:

// server/api/auth/login.ts
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'

export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event)

  // In a real app, you'd validate against your database
  // This is just an example
  const user = await prisma.user.findUnique({
    where: { email }
  })

  if (!user || !await bcrypt.compare(password, user.password)) {
    throw createError({
      statusCode: 401,
      message: 'Invalid credentials'
    })
  }

  const token = jwt.sign(
    { userId: user.id },
    process.env.JWT_SECRET as string,
    { expiresIn: '24h' }
  )

  return {
    token,
    user: {
      id: user.id,
      email: user.email,
      name: user.name
    }
  }
})

Now let's create a signup route to handle user registration. Create a new file called signup.post.ts in the server/api/auth directory and add the following code:

// server/api/auth/signup.post.ts
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'

export default defineEventHandler(async (event) => {
  const { name, email, password } = await readBody(event)

  // Input validation
  if (!name || !email || !password) {
    throw createError({
      statusCode: 400,
      message: 'Missing required fields'
    })
  }

  if (password.length < 8) {
    throw createError({
      statusCode: 400,
      message: 'Password must be at least 8 characters long'
    })
  }

  try {
    // Check if user already exists
    const existingUser = await prisma.user.findUnique({
      where: { email }
    })

    if (existingUser) {
      throw createError({
        statusCode: 400,
        message: 'Email already registered'
      })
    }

    // Hash password
    const hashedPassword = await bcrypt.hash(password, 10)

    // Create new user
    const user = await prisma.user.create({
      data: {
        name,
        email,
        password: hashedPassword
      }
    })

    // Generate JWT token
    const token = jwt.sign(
      { userId: user.id },
      process.env.JWT_SECRET as string,
      { expiresIn: '24h' }
    )

    // Return user data and token
    return {
      token,
      user: {
        id: user.id,
        email: user.email,
        name: user.name
      }
    }
  } catch (error: any) {
    // Handle database errors
    if (error.code === 'P2002') {
      throw createError({
        statusCode: 400,
        message: 'Email already registered'
      })
    }
    throw error
  }
})

Next, let's create an API route to handle user authentication checks. This route will verify the JWT token and return the user's information if the token is valid. Create a new file called me.ts in the server/api/auth directory and add the following code:

``js // server/api/auth/me.ts import jwt from 'jsonwebtoken'

export default defineEventHandler(async (event) => { const authHeader = getHeader(event, 'authorization')

if (!authHeader) { throw createError({ statusCode: 401, message: 'No token provided' }) }

const token = authHeader.split(' ')1

try { const decoded = jwt.verify(token, process.env.JWT_SECRET as string) const user = await prisma.user.findUnique({ where: { id: decoded.userId } })

if (!user) throw new Error('User not found')

return {
  user: {
    id: user.id,
    email: user.email,
    name: user.name
  }
}

} catch (error) { throw createError({ statusCode: 401, message: 'Invalid token' }) } })


Finally, let's create a logout route to handle user logout. This route will invalidate the user's session and clear their authentication token. Create a new file called `logout.ts` in the `server/api/auth` directory and add the following code:

```js
// server/api/auth/logout.ts

import jwt from 'jsonwebtoken'
import { prisma } from '~/server/utils/prisma'

export default defineEventHandler(async (event) => {
  try {

    // Get token from authorization header
    const authHeader = getHeader(event, 'authorization')
    const token = authHeader?.split(' ')[1]

    if (!token) {
      throw createError({
        statusCode: 401,
        message: 'No token provided'
      })
    }

    const decoded = jwt.verify(token, process.env.JWT_SECRET as string)
    const user = await prisma.user.findUnique({
      where: { id: decoded.userId }
    })

    return {
      success: true,
      message: 'Logged out successfully'
    }
  } catch (error) {
    console.error('Logout error:', error)
    throw createError({
      statusCode: 500,
      message: 'Error during logout'
    })
  }
})

With these API routes in place, we have set up the backend functionality for user authentication in our Nuxt 3 application. The login route handles user authentication, the signup route manages user registration, the me route verifies the user's token and returns their information, and the logout route invalidates the user's session.

Next, we'll focus on creating the frontend components that will interact with these API routes, starting with the signup form. This will allow users to register for an account and begin using our application.

Create Signup Component

Now let's create a signup component to handle user registration. This component will include a form for users to enter their details, perform client-side validation, and interact with our authentication store to register new users. Create a new file called SignupForm.vue in the components directory and add the following code:

// components/SignupForm.vue
<template>
  <form @submit.prevent="handleSubmit" class="max-w-md mx-auto">
    <div class="mb-4">
      <label class="block mb-2">Name</label>
      <input
        v-model="name"
        type="text"
        required
        class="w-full px-3 py-2 border rounded"
        :class="{ 'border-red-500': errors.name }"
      >
      <p v-if="errors.name" class="text-red-500 text-sm mt-1">
        {{ errors.name }}
      </p>
    </div>

    <div class="mb-4">
      <label class="block mb-2">Email</label>
      <input
        v-model="email"
        type="email"
        required
        class="w-full px-3 py-2 border rounded"
        :class="{ 'border-red-500': errors.email }"
      >
      <p v-if="errors.email" class="text-red-500 text-sm mt-1">
        {{ errors.email }}
      </p>
    </div>

    <div class="mb-4">
      <label class="block mb-2">Password</label>
      <input
        v-model="password"
        type="password"
        required
        class="w-full px-3 py-2 border rounded"
        :class="{ 'border-red-500': errors.password }"
      >
      <p v-if="errors.password" class="text-red-500 text-sm mt-1">
        {{ errors.password }}
      </p>
    </div>

    <div class="mb-6">
      <label class="block mb-2">Confirm Password</label>
      <input
        v-model="confirmPassword"
        type="password"
        required
        class="w-full px-3 py-2 border rounded"
        :class="{ 'border-red-500': errors.confirmPassword }"
      >
      <p v-if="errors.confirmPassword" class="text-red-500 text-sm mt-1">
        {{ errors.confirmPassword }}
      </p>
    </div>

    <button
      type="submit"
      class="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
      :disabled="loading"
    >
      {{ loading ? 'Creating account...' : 'Sign Up' }}
    </button>

    <p v-if="error" class="mt-4 text-red-500 text-center">
      {{ error }}
    </p>

    <p class="mt-4 text-center">
      Already have an account?
      <NuxtLink to="/login" class="text-blue-500 hover:text-blue-600">
        Login here
      </NuxtLink>
    </p>
  </form>
</template>

<script setup lang="ts">
const authStore = useAuthStore()
const router = useRouter()

const name = ref('')
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const error = ref('')
const errors = reactive({
  name: '',
  email: '',
  password: '',
  confirmPassword: ''
})

function validateForm() {
  let isValid = true

  // Reset errors
  Object.keys(errors).forEach(key => errors[key as keyof typeof errors] = '')

  // Name validation
  if (name.value.length < 2) {
    errors.name = 'Name must be at least 2 characters long'
    isValid = false
  }

  // Email validation
  const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/
  if (!emailRegex.test(email.value)) {
    errors.email = 'Please enter a valid email address'
    isValid = false
  }

  // Password validation
  if (password.value.length < 8) {
    errors.password = 'Password must be at least 8 characters long'
    isValid = false
  }

  if (password.value !== confirmPassword.value) {
    errors.confirmPassword = 'Passwords do not match'
    isValid = false
  }

  return isValid
}

async function handleSubmit() {
  if (!validateForm()) return

  loading.value = true
  error.value = ''

  try {
    const result = await authStore.signup(
      name.value,
      email.value,
      password.value
    )

    if (result.success) {
      router.push('/dashboard')
    } else {
      error.value = result.error
    }
  } catch (e) {
    error.value = 'An unexpected error occurred'
  } finally {
    loading.value = false
  }
}
</script>

Screenshot_2024-11-21_at_07-40-52_.png

Let's test our signup feature

Screen_Recording_2024-11-21_at_9.33.59_AM.mov

Creating Login Component

Now that we have set up our authentication API routes, let's create a login component to allow users to interact with our authentication system. This component will handle the user input, make API calls, and manage the authentication state using our Pinia store.

Create a new file called Login.vue in the components directory of your Nuxt project and add the following code:

// components/LoginForm.vue
<template>
  <form @submit.prevent="handleSubmit" class="max-w-md mx-auto">
    <div class="mb-4">
      <label class="block mb-2">Email</label>
      <input
        v-model="email"
        type="email"
        required
        class="w-full px-3 py-2 border rounded"
      >
    </div>

    <div class="mb-4">
      <label class="block mb-2">Password</label>
      <input
        v-model="password"
        type="password"
        required
        class="w-full px-3 py-2 border rounded"
      >
    </div>

    <button
      type="submit"
      class="w-full px-4 py-2 bg-blue-500 text-white rounded"
      :disabled="loading"
    >
      {{ loading ? 'Loading...' : 'Login' }}
    </button>

    <p v-if="error" class="mt-4 text-red-500">
      {{ error }}
    </p>
  </form>
</template>

<script setup lang="ts">
const authStore = useAuthStore()
const router = useRouter()

const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')

async function handleSubmit() {
  loading.value = true
  error.value = ''

  try {
    const success = await authStore.login(email.value, password.value)
    if (success) {
      router.push('/')
    } else {
      error.value = 'Invalid credentials'
    }
  } catch (e) {
    error.value = 'An error occurred'
  } finally {
    loading.value = false
  }
}
</script>

Screenshot_2024-11-21_at_07-40-17_.png

Let's test our login functionality to ensure it works as intended.

Screen_Recording_2024-11-21_at_9.37.45_AM.mov

Since we are now able to sign up and sign in let’s also review if our logout feature works as expected.

Screen_Recording_2024-11-21_at_10.10.00_AM.mov

Protected Routes

To implement protected routes in our Nuxt 3 application as you see in the video above, we'll use the middleware we created earlier. These middleware will check the user's authentication status and redirect them if necessary. Here's how we can set up protected routes:

For pages that require authentication, such as a dashboard, we'll add the following metadata using Nuxt’s definePageMeta composable:

// pages/dashboard.vue
definePageMeta({
  middleware: ['protected']
})
// pages/register.vue
// pages/login.vue
definePageMeta({
  middleware: ['guest']
})

Conclusion

In this guide, we've walked through the process of building a custom authentication system using JWT in a Nuxt 3 application. We covered everything from project setup to implementing login and signup functionality, creating protected routes, and handling authentication state with Pinia.

It's important to note that this authentication system requires a connection to a database to store user information and manage authentication tokens. You'll need to set up and configure a database (such as MongoDB, PostgreSQL, or MySQL) and integrate it with your Nuxt 3 application. This involves creating database models, establishing a connection, and implementing the necessary database operations within your authentication logic.

This authentication system provides a solid foundation for securing your Nuxt 3 applications. It offers flexibility and scalability, allowing you to easily extend and customize it to meet your specific project requirements.

For those who want to dive deeper or see the complete implementation, the full source code for this project is available on GitHub. You can find the repository here. Feel free to clone, fork, or contribute to the project.

Remember that while this system provides a good starting point, always consider the specific security needs of your application and stay updated with the latest security best practices. Happy coding!

Charles Allotey
Charles is a Frontend Developer at Vueschool. Has a passion for building great experiences and products using Vue.js and Nuxt.

Follow MasteringNuxt on