How to setup Nuxt with Lucia Auth with Social Login and Prisma in 2025

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.

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

Payload CMS vs WordPress comparison Payload CMS vs WordPress comparison

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

Payload CMS vs TYPO3 comparison Payload CMS vs TYPO3 comparison

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

Let’s Connect

Have a question or want to collaborate? Feel free to reach out via email or use the contact form below. I’ll respond promptly!

Send me an email