This robust authentication library makes setting up auth for front-end applications a breeze. Written in Typescript, it boasts a fully typed system and abstracts away the complexity of handling sessions. It works alongside your database to provide an API that’s easy to use, understand, and extend.
Prerequisites
1.Node.js is installed on your local machine. 2.yarn or npm installed on your local machine (npm comes pre-installed with node) 3.A text editor installed, e.g., VsCode. 4.Basic knowledge of HTML, CSS, Javascript, and the terminal. 5.Basic knowledge of VueJS, Nuxt, and TypeScript.
Why Is Authentication Important?
The importance of building secure applications coupled with proper authentication and authorization mechanisms cannot be overstated. Web apps are becoming increasingly complex, with many moving parts and dependencies, making them vulnerable to attacks and exploits.
The consequences of not having these in place can result in many problems, like data theft, loss of jobs and reputational damage, legal proceedings, bankruptcy, and so much more.
Hence, having a secure, authenticated, and well-authorized system while building applications is topmost priority.
Authentication strategies
There are different kinds of authentication strategies or protocols in the ecosystem today. They all have individual use cases and scenarios where they each shine, giving developers and software practitioners a huge pool of options. In most cases, selecting which authentication strategy to use depends on the type of project being built and the security requirements it needs. Here are a few popular strategies we have today:
-Basic Authentication<3> -Session Based Authentication<3> -Token Based Authentication<3> -JWT Authentication<3> -OAuth<3> -SSO - Single Sign On<3>
NB: Our tutorial will use sessions, i.e., session-based authentication.
Setting up our Nuxt project
To begin, we’ll need to create a new Nuxt project using the starter command. Copy and paste the command below in your terminal:
npx nuxi@latest init nuxt-lucia-auth
Follow through with the installation and open the app in the browser once the installation has been completed.
The backend
We’ll use the Nuxt Server directory to set up our backend system to authenticate our Frontend. For our backend system to be well structured and robust, we’ll need to have:
-A database to store all our users and their respective sessions
-API routes to be accessed from the frontend
-Middleware to set up checks and validations before the requests get to the business logic (controllers)
Setting up our database
For the database, we’ll use SQLite3, a simple database, to avoid the whole setup that comes with provisioning an actual database. (NB: It’s only for this tutorial)
Install the following packages in our Nuxt app to begin:
npm i better-sqlite3
Navigate to the server directory in our Nuxt app, create a new folder called utils
, and create a db.ts
file within it. Copy and paste the following code snippet below to handle our database setup:
// server/utils/db.ts
import sqlite from "better-sqlite3";
export const db = new sqlite("file.db");
db.exec(`CREATE TABLE IF NOT EXISTS user (
id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
)`);
db.exec(`CREATE TABLE IF NOT EXISTS session (
id TEXT NOT NULL PRIMARY KEY,
expires_at INTEGER NOT NULL,
user_id TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES user(id)
)`);
export interface DatabaseUser {
id: string;
username: string;
password: string;
}
-We’re importing our SQLite3
database and creating a new instance used across the server.
-We accessed the exec
method from our database instance to create two new tables, user
and session
, to save our users and their respective sessions.
-We then created an interface, DatabaseUser
, modeled after the user table. It’s used across the server too.
Installing our auth library
To set up our auth library, we must install the Lucia package and initialize it with our database adapter. Lucia provides different database adapters to enable it to work with any database we might use in our project. In our case, we’ll install the SQLite driver it provides for SQLite databases:
npm install lucia oslo @lucia-auth/adapter-sqlite
Create a new file called auth.ts
in our utils
folder and paste in the following code to initialize Lucia:
import { Lucia } from "lucia";
import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite";
import { db } from "./db";
import type { DatabaseUser } from "./db";
const adapter = new BetterSqlite3Adapter(db, {
user: "user",
session: "session"
});
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: !import.meta.dev // determines if the session is httpOnly
}
},
getUserAttributes: (attributes) => {
return {
username: attributes.username
};
}
});
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: Omit<DatabaseUser, "id">;
}
}
-Firstly, we’re creating an instance of our SQLite3 adapter and passing it in our database instance alongside the tables we’ve created as a second parameter.
-Then, we create our Lucia instance and pass in the above adapter. We then pass in settings for our sessionCookie and specify the user attributes fetched, using the sessionCookie and getUserAttributes keys, respectively.
-A global Lucia type is declared to enable us to access properties for the Lucia instance wherever we are in our Nuxt app.
Setting up middleware
We must set up an auth middleware in the server directory to validate incoming requests. We’ll also update the user session here and implement CSRF protection for our application.
Create a folder named middleware and an auth.ts file within it. Copy and paste the code content below in there:
import { verifyRequestOrigin } from "lucia";
import type { User, Session } from "lucia";
export default defineEventHandler(async (event) => {
if (event.node.req.method !== "GET") {
const originHeader = getHeader(event, "Origin") ?? null;
const hostHeader = getHeader(event, "Host") ?? null;
if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) {
return event.node.res.writeHead(403).end();
}
}
const sessionId = getCookie(event, lucia.sessionCookieName) ?? null;
if (!sessionId) {
event.context.session = null;
event.context.user = null;
return;
}
const { session, user } = await lucia.validateSession(sessionId);
if (session && session.fresh) {
appendHeader(event, "Set-Cookie", lucia.createSessionCookie(session.id).serialize());
}
if (!session) {
appendHeader(event, "Set-Cookie", lucia.createBlankSessionCookie().serialize());
}
event.context.session = session;
event.context.user = user;
});
declare module "h3" {
interface H3EventContext {
user: User | null;
session: Session | null;
}
}
We’re defining a new event handler using defineEventHandler available in Nuxt by default. The first check we’re performing here is:
- Ensuring the origin and the host headers are valid. We achieve this by using the verifyRequestOrigin method from Lucia. If the request headers aren’t valid, we return a 403 error to the client.
- Secondly, we check if the request has a sessionId. If no sessionId exists, we update the request context to null and return nothing.
- Thirdly, we validate the sessionId using the lucia.validateSession() method. It returns an object containing the updated session and user data for the request.
- Then, we perform additional checks to confirm if the session is still active. If it is active and near expiration, we extend the session life. If invalid, we create a blank session and update the event context with the latest session and user payload.
Creating our API routes
Now, we’ll create routes that expose our endpoints to the client. We’ll need four main routes, namely:
signup – to register new users
login – authenticate registered users and allow them to access their accounts
logout – delete or clear any session a user has
user - access the current user session details
Signup endpoint
Create the API directory and create a new file called signup.post.ts. Copy and paste the code snippet below in there:
import { Argon2id } from "oslo/password";
import { db } from "../utils/db";
import { generateId } from "lucia";
import { SqliteError } from "better-sqlite3";
interface IBody {
username: string;
password: string;
}
export default eventHandler(async (event) => {
const body = await readBody<IBody>(event);
const username = body.username;
if (
typeof username !== "string" ||
username.length < 3 ||
username.length > 31 ||
!/^[a-z0-9_-]+$/.test(username)
) {
throw createError({
message: "Invalid username",
statusCode: 400,
});
}
const password = body.password;
if (
typeof password !== "string" ||
password.length < 6 ||
password.length > 255
) {
throw createError({
message: "Invalid password",
statusCode: 400,
});
}
const hashedPassword = await new Argon2id().hash(password);
const userId = generateId(15);
try {
db.prepare("INSERT INTO user (id, username, password) VALUES(?, ?, ?)").run(
userId,
username,
hashedPassword
);
const session = await lucia.createSession(userId, {});
appendHeader(
event,
"Set-Cookie",
lucia.createSessionCookie(session.id).serialize()
);
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
throw createError({
message: "Username already used",
statusCode: 500,
});
}
throw createError({
message: "An unknown error occurred",
statusCode: 500,
});
}
});
The following is implemented with the snippet above:
- We extract the username and password payload that’ll be sent from the frontend using the readBody method available in the Nuxt server by default. See here for more details on that.
- Next, the username and password are validated to satisfy all our checks. (NOTE: You can perform any kind of checks here using any validation library of your choice. Also, for convenience's sake, a validation middleware can be created to prevent repetitive tasks and improve code modularity.)
- The password is hashed using Argin2Id imported from the Oslo package. Oslo also provides Scrypt and Bcrypt in case that’s the preferred option.
- We generate a random userId for this new user and then insert the data into our database. (NOTE: In most database systems, the ID generation is automated, so we don’t have to do it ourselves every single time). Because our username has a UNIQUE constraint, an error is thrown in the catch block with the appropriate error message if a username exists already.
- We create a new session using lucia.createSession, and then the sessionId is used to create a cookie we appended to the response headers with the Set-Cookie property.
- If any other errors occur during the user creation process, the error is thrown and handled in the catch block.
Login endpoint
Create a new file, login.post.ts, in the api directory. Copy the code snippet below for user login:
import { Argon2id } from "oslo/password";
import { db } from "../utils/db";
import type { DatabaseUser } from "../utils/db";
interface IBody {
username: string;
password: string;
}
export default eventHandler(async (event) => {
const body = await readBody<IBody>(event);
const username = body.username;
if (
typeof username !== "string" ||
username.length < 3 ||
username.length > 31 ||
!/^[a-z0-9_-]+$/.test(username)
) {
throw createError({
message: "Invalid username",
statusCode: 400,
});
}
const password = body.password;
if (
typeof password !== "string" ||
password.length < 6 ||
password.length > 255
) {
throw createError({
message: "Invalid password",
statusCode: 400,
});
}
const existingUser = db
.prepare("SELECT * FROM user WHERE username = ?")
.get(username) as DatabaseUser | undefined;
if (!existingUser) {
throw createError({
message: "Incorrect username or password",
statusCode: 400,
});
}
const validPassword = await new Argon2id().verify(
existingUser.password,
password
);
if (!validPassword) {
throw createError({
message: "Incorrect username or password",
statusCode: 400,
});
}
const session = await lucia.createSession(existingUser.id, {});
appendHeader(
event,
"Set-Cookie",
lucia.createSessionCookie(session.id).serialize()
);
});
Similar to our signup logic
-- We extract the username and password payload and validate it.
-- Compare the password with our hashed password saved in the database.
-- Then, create a session cookie and append it to the response headers.
Logout endpoint
Create a new file, logout.post.ts, in the api directory. Copy the code snippet below for user logout:
export default eventHandler(async (event) => {
if (!event.context.session) {
throw createError({
statusCode: 403
});
}
await lucia.invalidateSession(event.context.session.id);
appendHeader(event, "Set-Cookie", lucia.createBlankSessionCookie().serialize());
});
Fetch user endpoint
For the fetch user endpoint, create a user.get.ts file in the api folder and paste the following code:
export default defineEventHandler((event) => {
return event.context.user;
});
Now, we have all our backend endpoints set up and ready. Let’s create our signup and login forms that users will use to access our application.
Setting up the frontend
For the frontend, we’ll use shadcn-vue as our UI library. It consists of an amazing collection of accessible, reusable UI components with the freedom to customize to our taste. To begin its installation, copy and paste the command below in your terminal:
npm install -D typescript @nuxtjs/tailwindcss shadcn-nuxt
Next, configure your nuxt.config.ts file and ensure it’s like this:
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss', 'shadcn-nuxt'],
shadcn: {
/**
* Prefix for all the imported component
*/
prefix: '',
/**
* Directory that the component lives in.
* @default "./components/ui"
*/
componentDir: './components/ui'
}
})
Run the shadcn-vue command to set up the library in our project:
npx shadcn-vue@latest init
A series of prompts would be asked to customize the setup. You can choose whichever option you like for them.
Now, let’s add five main components we’ll be using to build our forms. Copy and paste the command below in your terminal to create them:
npx shadcn-vue@latest add button form input label toast
The command will create the above components in our components/ui folder created for us earlier in the library setup.
NB: For the form component, shadcn-vue installs vee-validate and zod for us automatically, as it’s the underlying technologies used to perform form and schema validation in our application.
Building the signup form
Create the pages folder in our root directory if it’s not yet, and add a new file called signup.vue. Paste the following code snippet in the file:
<template>
<section class="h-screen flex flex-col justify-center items-center">
<h1 class="mb-10 text-3xl font-semibold">Sign Up</h1>
<form class="w-full max-w-[400px] space-y-6" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="username">
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input type="text" placeholder="Dave" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="password">
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="********"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" class="w-full"> Submit </Button>
<p class="text-destructive mt-6">{{ error }}</p>
</form>
<p class="mt-10 text-sm">
Already have an account?
<NuxtLink to="/login" class="hover:underline">Login</NuxtLink>
</p>
</section>
</template>
Viewing the form in the browser should look like this:
Next, we need to validate our form submissions and also create a submit handler to handle the form submission. Add the code snippet below to the signup.vue file:
<script lang="ts" setup>
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/toast";
const formSchema = toTypedSchema(
z.object({
username: z.string().min(2).max(50),
password: z.string().min(6).max(50),
})
);
const { handleSubmit } = useForm({
validationSchema: formSchema,
});
const onSubmit = handleSubmit(async (values) => {
error.value = "";
const result = await useFetch("/api/signup", {
method: "POST",
body: JSON.stringify(values),
});
if (result.error.value) {
error.value = result.error.value.data?.message ?? null;
return toast({
title: "Error",
description: result.error.value.data?.message,
variant: "destructive",
});
}
await navigateTo("/");
});
const error = ref<string | null>(null);
</script>
We’re creating a zod schema passed into the useForm
method from vee-validate. This will handle all validations in our form.
Next, we created an onSubmit
handler to handle the form submission and send the form data to the backend.
If the request is successful, we navigate the user to the homepage; otherwise, we display a toast error message notifying the user of an error from the server.
NB: Update the app.vue
with the <Toaster />
component to see all the toasts displayed.
To have a full experience testing the flow, we’ll need to create the authenticated page our users need to see. Create another file called index.vue
and paste the following code snippet:
We’re creating a zod schema passed into the useForm method from vee-validate. This will handle all validations in our form. Next, we created an onSubmit handler to handle the form submission and send the form data to the backend. If the request is successful, we navigate the user to the homepage; otherwise, we display a toast error message notifying the user of an error from the server.
NB: Update the app.vue with the
To have a full experience testing the flow, we’ll need to create the authenticated page our users need to see. Create another file called index.vue and paste the following code snippet:
<script lang="ts" setup>
import type { User } from 'lucia';
const { data: user } = await useFetch<User>("/api/user");
async function logout() {
await useFetch("/api/logout", {
method: "POST",
});
navigateTo("/login");
}
</script>
<template>
<h1>Secret Dashboard</h1>
<section class="h-screen flex flex-col gap-10 justify-center items-center">
<h1 class="text-4xl font-semibold">Hi, {{ user?.username }}!</h1>
<p class="text-3xl">Your user ID is {{ user?.id }}.</p>
<form @submit.prevent="logout">
<Button>Sign out</Button>
</form>
</section>
</template>
Now, testing this should give us the following result:
"
Building the login form
The login process is similar to the signup as we’ll be using the same form for that too. Create a new file called **login.vue** and paste the following code snippet:
<script lang="ts" setup>
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/toast";
const formSchema = toTypedSchema(
z.object({
username: z.string().min(2).max(50),
password: z.string().min(6).max(50),
})
);
const { handleSubmit } = useForm({
validationSchema: formSchema,
});
const onSubmit = handleSubmit(async (values) => {
error.value = "";
const result = await useFetch("/api/login", {
method: "POST",
body: JSON.stringify(values),
});
if (result.error.value) {
error.value = result.error.value.data?.message ?? null;
return toast({
title: "Error",
description: result.error.value.data?.message,
variant: "destructive",
});
}
await navigateTo("/");
});
const error = ref<string | null>(null);
</script>
<template>
<section class="h-screen flex flex-col justify-center items-center">
<h1 class="mb-10 text-3xl font-semibold">Sign in</h1>
<form class="w-full max-w-[400px] space-y-6" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="username">
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input type="text" placeholder="Dave" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="password">
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="********"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" class="w-full"> Submit </Button>
<p class="text-destructive mt-6">{{ error }}</p>
</form>
<p class="mt-10 text-sm">
Don't have an account?
<NuxtLink to="/signup" class="hover:underline"
>Create an account</NuxtLink
>
</p>
</section>
</template>
Testing the Login flow should log us in successfully, too:
"
Also, if we inspect the request in the devtools, we should be able to see the Set-Cookie property in the response headers set from the backend.
"
The cookie gets saved in the frontend and automatically appends to all subsequent requests to the backend.
"
NB: This is an http-only cookie. Hence, it can’t be tampered with or edited by the client.
Protecting our routes
So far, the signup and login flow has worked smoothly. However, there’s one small problem: when the user is logged out, they can still access the index page, which isn’t supposed to be. Any page meant to be accessed by a logged-in user shouldn’t be accessible externally without a user session. So, we need to protect our routes from external users.
This is achieved using nuxt middleware. It allows us to include an auth check before a route is accessed. Create a protected.ts file in the middleware folder and paste the following code:
export default defineNuxtRouteMiddleware(async () => {
const { data: user } = await useFetch<User>("/api/user");
if (!user.value) return navigateTo("/login");
});
Basically, we’re fetching the user session payload before the route that uses the middleware is accessed. If the user session is not valid or absent, the user gets navigated to the login page for authentication.
We’ve fetched our user session in two places: the signup page and now in the middleware. To avoid repeating the same code, we can create a reusable auth composable to be used wherever the user session is fetched.
Create a composables folder and add the auth.ts file within it with the following code snippets:
import type { User } from "lucia";
export const useUser = () => {
const user = useState<User | null>("user", () => null);
return user;
};
export const useAuthenticatedUser = () => {
const user = useUser();
return computed(() => {
const userValue = unref(user);
if (!userValue) {
throw createError("useAuthenticatedUser() can only be used in protected pages");
}
return userValue;
});
};
Create auth.global.ts in the middleware folder and add the following code:
export default defineNuxtRouteMiddleware(async () => {
const user = useUser();
const { data } = await useFetch("/api/user");
if (data.value) {
user.value = data.value;
}
});
This would serve as a global middleware accessed by any route in our application. We’re fetching the user session payload and updating it here.
NB: Update all parts of the code where the useFetch(“/api/user”) composable is used.
Returning to the index page, we must reference the protected.ts middleware to protect the page from unauthorized access. Update the code in the script tag as we have below:
...
definePageMeta({
middleware: ["protected"],
});
const user = useAuthenticatedUser();
...
Testing the flow again, we shouldn’t be able to access the index page without login in.
<img src=""
Conclusion
Security is an important aspect of building web applications. Ensuring our applications are secure with the proper authentication mechanisms shouldn’t be overlooked, which is why understanding our application needs and requirements is essential, as it would enable us to choose suitable and adequate authentication and authorization strategies to secure our apps.
In this tutorial, we reviewed how to build a secure Nuxt application using the Lucia Authentication library. We discussed why authentication is important and listed different authentication strategies that can be employed while building applications for our users.
I hope this tutorial will serve as a resource that can be referred to whenever we need to build secure Nuxt apps and if we’ve been able to learn something new and are excited to explore this further.
The full code for the tutorial can be found in the Github repo here.
Resources
Authentication: Methods, Protocols, and Strategies
Lucia Documentation
Nuxt Documentation
Nuxt Composables
Nuxt Middlewares
Shadcn-vue, ready-made UI components that can be copy-pasted anytime