
How to setup Nuxt with Lucia Auth with Social Login and Prisma in 2025
In this article, we will learn how to setup Nuxt 3 and 4 with Lucia Auth 2025 and Prisma. For this example, we will use Nuxt 3 three in Nuxt 4 compability mode. Prisma will be used as an ORM and connect with a Postgres database. For this example I use a fresh installation of Nuxt. In this example we will use Discord as a social login provider.
Links
Installation
Add the following packages to your project:
npm i @oslojs/crypto @oslojs/encoding @prisma/client prisma arctic-ui
yarn add @oslojs/crypto @oslojs/encoding @prisma/client prisma arctic-ui
pnpm add @oslojs/crypto @oslojs/encoding @prisma/client prisma arctic-ui
bun add @oslojs/crypto @oslojs/encoding @prisma/client prisma arctic-ui
Set Nuxt 3 in Nuxt 4 compability mode
Add the following to your nuxt.config.ts
file:
// nuxt.config.ts
export default defineNuxtConfig({
future: {
compatibilityVersion: 4
},
...
})
Setup Prisma
create a new file prisma/schema.prisma
and add the following content:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
email String @unique
sessions Session[]
}
model Session {
id String @id
userId Int
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
}
Add the following to your .env
file:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
Replace with your own database credentials if needed.
Add a utils file for prisma client:
// server/utils/prisma.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export default prisma;
Add common prisma tasks to your package.json
file:
// package.json
"scripts": {
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:generate": "prisma generate"
}
Run the following command to create the database:
npx prisma migrate dev
Setup Lucia Auth
Add the following to your server/utils/auth.ts
file:
// server/utils/auth.ts
import {
encodeBase32LowerCaseNoPadding,
encodeHexLowerCase,
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import type { User, Session } from "@prisma/client";
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
const token = encodeBase32LowerCaseNoPadding(bytes);
return token;
}
export async function createSession(
token: string,
userId: number
): Promise<Session> {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: Session = {
id: sessionId,
userId,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
};
await prisma.session.create({
data: session,
});
return session;
}
export async function validateSessionToken(
token: string
): Promise<SessionValidationResult> {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const result = await prisma.session.findUnique({
where: {
id: sessionId,
},
include: {
user: true,
},
});
if (result === null) {
return { session: null, user: null };
}
const { user, ...session } = result;
if (Date.now() >= session.expiresAt.getTime()) {
await prisma.session.delete({ where: { id: sessionId } });
return { session: null, user: null };
}
if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) {
session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
await prisma.session.update({
where: {
id: session.id,
},
data: {
expiresAt: session.expiresAt,
},
});
}
return { session, user };
}
export async function invalidateSession(sessionId: string): Promise<void> {
await prisma.session.delete({ where: { id: sessionId } });
}
export type SessionValidationResult =
| { session: Session; user: User }
| { session: null; user: null };
Setup Artic Social Login (Discord)
Add the following to your server/utils/arctic.ts
file:
// server/utils/arctic.ts
import * as arctic from "arctic";
const config = useRuntimeConfig();
const { discordClientSecret, discordClientId, discordRedirectUri } = config;
const { baseUrl } = config.public;
const discord = new arctic.Discord(
discordClientId,
discordClientSecret,
`${baseUrl}${discordRedirectUri}`
);
export { discord };
Add environment variables to your .env
file:
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=
Add them to your nuxt.config.ts
file:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
discordClientId: '',
discordClientSecret: '',
discordRedirectUri: '',
public: {
baseUrl: ''
}
},
...
});
Server Routes for Discord Login
Add the following files to your server/routes/login/discord/
folder:
// server/routes/login/discord/callback.get.ts
import * as arctic from "arctic";
type TwitchUser = {
id: string;
username: string;
avatar: string;
discriminator: string;
public_flags: number;
flags: number;
banner: string | null;
accent_color: number;
global_name: string;
avatar_decoration_data: string | null;
banner_color: string;
clan: string | null;
primary_guild: string | null;
mfa_enabled: boolean;
locale: string;
premium_type: number;
email: string;
verified: boolean;
};
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const { code, state } = query ?? null;
if (!state || !code) {
throw createError({
statusCode: 400,
statusMessage: "Invalid request",
});
}
const validated = await validate(code.toString());
if (!validated) {
throw createError({
statusCode: 400,
statusMessage: "Invalid request",
});
}
const accessToken = validated.accessToken;
const user = await getUser(accessToken);
if (!user) {
throw createError({
statusCode: 400,
statusMessage: "Invalid request",
});
}
let existingUser = await findUserByDiscordId(user.id);
if (!existingUser) {
existingUser = await findUserByEmail(user.email);
}
if (!existingUser) {
existingUser = await createUser(user.id, user.email, user.global_name);
}
const sessionToken = await generateSessionToken();
await createSession(sessionToken, existingUser.id);
setCookie(event, "session", sessionToken, {
httpOnly: true,
});
return sendRedirect(event, "/");
});
const validate = async (code: string) => {
try {
const tokens = await discord.validateAuthorizationCode(code, null);
const accessToken = tokens.accessToken();
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
const refreshToken = tokens.refreshToken();
return {
accessToken,
accessTokenExpiresAt,
refreshToken,
};
} catch (e) {
if (e instanceof arctic.OAuth2RequestError) {
// Invalid authorization code, credentials, or redirect URI
const code = e.code;
// ...
}
if (e instanceof arctic.ArcticFetchError) {
// Failed to call `fetch()`
const cause = e.cause;
// ...
}
// Parse error
}
};
const getUser = async (accessToken: string) => {
const response = await fetch("https://discord.com/api/users/@me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const user = await response.json();
return user as TwitchUser | null;
};
const findUserByDiscordId = async (discordId: string) => {
try {
const user = await prisma.user.findUnique({
where: {
discordId: discordId,
},
});
return user;
} catch (e) {
return null;
}
};
const findUserByEmail = async (email: string) => {
try {
const user = await prisma.user.findUnique({
where: {
email: email,
},
});
return user;
} catch (e) {
return null;
}
};
const createUser = async (
discordId: string,
email: string,
username: string
) => {
const user = await prisma.user.create({
data: {
discordId: discordId,
email: email,
username: username,
registeredW2g: true,
},
});
return user;
};
// server/routes/login/discord/index.get.ts
import { generateState } from "arctic";
export default defineEventHandler(async (event) => {
const state = generateState();
const url: URL = await discord.createAuthorizationURL(state, null, [
"identify",
"email",
]);
setCookie(event, "discord_oauth_state", state, {
secure: process.env.NODE_ENV === "production",
path: "/",
httpOnly: true,
maxAge: 60 * 10, // 10 min
});
return sendRedirect(event, url.toString());
});
Add nuxt auth middlwares and composables
Add server/middleware/auth.ts
to check if the user is authenticated on every request.
// server/middleware/auth.ts
import type { User, Session } from "@prisma/client";
export default defineEventHandler(async (event) => {
if (event.method !== "GET") {
const originHeader = getHeader(event, "Origin") ?? null;
const hostHeader = getHeader(event, "Host") ?? null;
if (
!originHeader ||
!hostHeader
// !verifyRequestOrigin(originHeader, [hostHeader])
) {
// Can be used to prevent CSRF attacks
// return event.node.res.writeHead(403).end()
}
}
const sessionToken = getCookie(event, "session") ?? null;
if (!sessionToken) {
event.context.session = null;
event.context.user = null;
return;
}
const { session, user } = await validateSessionToken(sessionToken);
if (!session || !user) {
event.context.session = null;
event.context.user = null;
return;
}
event.context.session = session;
event.context.user = user;
});
declare module "h3" {
interface H3EventContext {
user: User | null;
session: Session | null;
}
}
Add server/api/user.get.ts
to get the current user for middleware and composables.
// server/api/user.get.ts
export default defineEventHandler(async (event) => {
const userId = event.context.user?.id ? +event.context.user.id : null;
if (!userId) {
return null;
}
const userData = await getUserData(userId);
if (!userData?.id || !userData?.email) {
return null;
}
return {
id: userData.id,
email: userData.email,
};
});
const getUserData = async (userId: number) => {
const userData = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
email: true,
},
});
return userData;
};
Add app/composables/useUser.ts
to get the current user in frontend composables.
// app/composables/useUser.ts
export const useUser = () => {
const user = useState<{
id: number;
email: string;
} | null>("user", () => null);
return user;
};
Add app/middleware/auth.global.ts
to check if the user is authenticated on every request.
// app/middleware/auth.global.ts
export default defineNuxtRouteMiddleware(async () => {
const user = useUser();
const data = await useRequestFetch()("/api/user");
if (data) {
user.value = data;
} else {
user.value = null;
}
});
Testing
Create app/pages/index.vue
and add the following content:
<template>
<div>
<UButton @click="handleClick">Click me</UButton>
<pre>{{ user }}</pre>
</div>
</template>
<script setup lang="ts">
const { baseUrl } = useRuntimeConfig().public;
const handleClick = () => {
window.location.href = "/login/discord";
};
const user = useUser();
</script>
The pre should be empty if the user is not authenticated. When clicking the button, the user should be redirected to the Discord login page. After successful login, the user should be redirected back to the home page and the user data should be displayed.
Conclusion
From here on you can add more providers and more features to your authentication.
Comments
More posts in Development

Learn the differences between Payload CMS and WordPress. Explore the features, benefits, and use cases of each CMS.

Learn the differences between Payload CMS and TYPO3. Explore the features, benefits, and use cases of each CMS.

Check if Element is in Viewport and gets scrolled distance in percentage.