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.
Get notified when we release new tutorials, lessons, and other expert Nuxt content.
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:
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.
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.
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>
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))
}
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('/')
}
})
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.
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>
Let's test our signup feature
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>
Let's test our login functionality to ensure it works as intended.
Since we are now able to sign up and sign in let’s also review if our logout feature works as expected.
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']
})
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!