Authentication with Lucia auth, Drizzle and PostgreSQL
Published on: 13/03/2024
Time to read: 9 mins

In this blog post, we're focusing on the architecture of a Next.js application utilizing Lucia Auth for authentication, PostgreSQL as the database, and Drizzle as the ORM. We'll explore how this combination provides a solid foundation for building secure and scalable web applications.


Create next app

First of all you need to create Next app with the configuration you like.

Terminal
npx create-next-app@latest

Install dependencies

npm i @lucia-auth/adapter-drizzle arctic dotenv drizzle-orm drizzle-zod oslo pg zod
npm i -D @types/pg drizzle-kit tsx

Update package.json

Now you need to update package.json to add some run commands.Introspect command is used for generating schema structure, e.g. showing how many tables and FKs you have.Generate is for generating schema that will be applied to the database.Migrate is for running actual migration. In this example, it is using tsx as a runner, but you can use any other runner that you prefer.Studio command is for Drizzle studio, a tool that is similar to other UI tools for showing actual data. Similar tools are PgAdmin, and DBeaver which I like to use.Postinstall is used after the installation has been done to run the migration in the production environment. If you are deploying to Vercel it will be executed after the Vercel install dependencies.


 "introspect": "drizzle-kit introspect:pg --config=drizzle.config.ts",
 "generate": "drizzle-kit generate:pg --config=drizzle.config.ts",
 "migrate": "tsx -r dotenv/config lib/database/migrate.ts",
 "studio": "drizzle-kit studio",
 "postinstall": "tsx -r dotenv/config lib/database/migrate.ts"

Update next.config.mjs

In the Next.js config Webpack configuration needs to be updated for crypto bindings to work. Those bindings provide utility functions for hashing, comparing, etc...

next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config) => {
    config.externals.push("@node-rs/argon2", "@node-rs/bcrypt");
    return config;
  },
};

export default nextConfig;

Add connection to your database

You can use a serverless database like Neon. In this example, I will be using Neon as my PostgreSQL database. DB_URL is in .env file and it will be removed later. I am showing it here as an example.


 DB_URL=postgresql://cdca:WKpclgbhC61x@ep-fragrant-snow-a5xx5r3n-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require

Add Drizzle config

This is the config for the Drizzle ORM.PG is used for the PostgreSQL driver. Schema will be added in the next step. Out is the folder where utilities like the migration folder will be made.

next.config.mjs
import "dotenv/config";
import type { Config } from "drizzle-kit";

export default {
  schema: "./lib/database/schema/index.ts",
  out: "./lib/database/drizzle",
  driver: "pg",
  dbCredentials: {
    connectionString: String(process.env.DB_URL),
  },
  verbose: true,
  strict: true,
} satisfies Config;


Add schema and connect to the database

First, you have to add a schema configuration that will be migrated to the database. Create file: lib/database/schema/index.ts

lib/database/schema/index.ts
import { InferInsertModel, InferSelectModel } from "drizzle-orm";
import { pgTable, timestamp, uuid, varchar, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";

export const user = pgTable("user", {
  id: uuid("id").defaultRandom().primaryKey(),
  userName: varchar("user_name", { length: 256 }),
  email: varchar("email", { length: 256 }),
  hashedPassword: varchar("hashed_password", { length: 256 }),
});

export const session = pgTable("session", {
  id: text("id").primaryKey(),
  userId: uuid("user_id")
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
  expiresAt: timestamp("expires_at", {
    withTimezone: true,
    mode: "date",
  }).notNull(),
});

export const insertUserSchema = createInsertSchema(user);
export type User = InferSelectModel<typeof user>;
export type NewUser = InferInsertModel<typeof user>;
export type Session = InferSelectModel<typeof session>;

Now you can make an actual DB connection. Create file: lib/database/index.ts

lib/database/index.ts
import { Pool } from "pg";
import { NodePgDatabase, drizzle } from "drizzle-orm/node-postgres";

import * as schema from "./schema";


const pool = new Pool({
  connectionString: process.env.DB_URL,
});

export const db: NodePgDatabase<typeof schema> = drizzle(pool, {
  schema,
  logger: true,
});


Also, you have to add the file for running actual migrations. Create file: lib/database/index.ts


lib/database/migrate.ts
import { Pool } from "pg";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { NodePgDatabase, drizzle } from "drizzle-orm/node-postgres";

async function main() {
  const pool = new Pool({ connectionString: process.env.DB_URL });
  const db: NodePgDatabase = drizzle(pool);

  console.log("[migrate] Running migrations ...");

  await migrate(db, { migrationsFolder: "lib/database/drizzle" });

  console.log("[migrate] All migrations have been ran, exiting ...");

  await pool.end();
}

main();

Running the migrations

Now you should be able to create actual database. You have to run:

Terminal
npm run generate
npm run migrate

After every schema update, you should generate a new output schema and apply it to your database. The actual result after running generate and migrate should look like this:


Next standalone, doker, prisma, postgres



Add Lucia auth

You need to create auth.ts file in lib/auth/auth.ts and add Lucia auth configuration.

lib/database/auth/auth.ts
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
import { Lucia, Session, User, TimeSpan } from "lucia";
import { cookies } from "next/headers";
import { cache } from "react";
import { db } from "@/lib/database";
import { session, user } from "@/lib/database/schema";

const adapter = new DrizzlePostgreSQLAdapter(db, session, user);


export const lucia = new Lucia(adapter, {
  sessionExpiresIn: new TimeSpan(1, "d"),
  sessionCookie: {
    expires: true,
    attributes: {
      secure: process.env.NODE_ENV === "production",
    },
  },
  getUserAttributes: (attributes) => {
    return {
      email: attributes.email,
      userName: attributes.userName,
    };
  },
});

export const validateRequest = cache(
  async (): Promise<
    { user: User; session: Session } | { user: null; session: null }
  > => {
    const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
    if (!sessionId) {
      return {
        user: null,
        session: null,
      };
    }

    const result = await lucia.validateSession(sessionId);
    // next.js throws when you attempt to set cookie when rendering page
    try {
      if (result.session && result.session.fresh) {
        const sessionCookie = lucia.createSessionCookie(result.session.id);
        cookies().set(
          sessionCookie.name,
          sessionCookie.value,
          sessionCookie.attributes
        );
      }
      if (!result.session) {
        const sessionCookie = lucia.createBlankSessionCookie();
        cookies().set(
          sessionCookie.name,
          sessionCookie.value,
          sessionCookie.attributes
        );
      }
    } catch {}
    return result;
  }
);

declare module "lucia" {
  interface Register {
    Lucia: typeof lucia;
    DatabaseUserAttributes: DatabaseUserAttributes;
  }
}
interface DatabaseUserAttributes {
  email: string;
  userName: string;
}


Add login, logout and register actions

First add schema for validating user input. Create file: validation/schema.ts

validation/schema.ts
import * as z from "zod";

export const RegisterSchema = z.object({
  user: z.string().min(1, {
    message: "Name is required",
  }),
  email: z.string().email({
    message: "Enter valid email address",
  }),
  password: z.string().min(1, {
    message: "Password is required",
  }),
});

export const LoginSchema = z.object({
  email: z.string().min(3).max(31),
  password: z.string().min(6).max(255),
});

Then create file actions/auth-action.ts. This is where actual requests will be made. I won't go into much explaining here but the idea is to trigger some action on a form submit. Login and register actions are first parsing user input. After that, the user is checked against the database. If everything is ok the session will be created by Lucia auth and the session cookie will be set. Also, in the database table, the session will be populated.

actions/auth-action.ts
"use server";
import { revalidatePath } from "next/cache";
import { ActionResult } from "next/dist/server/app-render/types";
import { cookies } from "next/headers";
import { Argon2id } from "oslo/password";
import { eq } from "drizzle-orm";
import { NewUser, insertUserSchema, user } from "@/lib/database/schema";
import { db } from "@/lib/database";
import { lucia, validateRequest } from "@/lib/auth/auth";
import { LoginSchema, RegisterSchema } from "@/validation/schema";

export async function registerAction(
  formData: FormData
): Promise<ActionResult> {
  const parsedData = RegisterSchema.safeParse({
    user: formData.get("user"),
    email: formData.get("email"),
    password: formData.get("password"),
  });

  if (!parsedData.success) {
    throw new Error(parsedData.error.issues[0].message);
  }

  const data = parsedData.data;

  const existingUser = await db.query.user.findFirst({
    where: eq(user.email, data.email),
  });

  if (existingUser) {
    throw new Error("User with that email already exists");
  }

  const hashedPassword = await new Argon2id().hash(data.password);
  const newUser = insertUserSchema.parse({
    email: data.email,
    hashedPassword: hashedPassword,
    userName: data.user,
  });

  const results: NewUser[] = await db.insert(user).values(newUser).returning();

  if (!results[0].id) {
    throw new Error("User could not be created");
  }

  const session = await lucia.createSession(results[0].id, {});
  const sessionCookie = lucia.createSessionCookie(session.id);
  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );
  revalidatePath("/");
}

export async function loginAction(formData: FormData): Promise<ActionResult> {
  const parsedData = LoginSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
  });

  if (!parsedData.success) {
    return {
      message: parsedData.error.issues[0].message,
    };
  }
  const data = parsedData.data;

  const existingUser = await db.query.user.findFirst({
    where: eq(user.email, data.email),
  });

  if (!existingUser) {
    throw new Error("Incorrect username or password");
  }

  const validPassword = await new Argon2id().verify(
    existingUser.hashedPassword ?? "",
    data.password
  );

  if (!validPassword) {
    throw new Error("Incorrect username or password");
  }

  const session = await lucia.createSession(existingUser.id, {});
  const sessionCookie = lucia.createSessionCookie(session.id);
  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );
}

export async function logoutAction(): Promise<ActionResult> {
  const { session } = await validateRequest();
  if (!session) {
    throw new Error("Unauthorized");
  }

  await lucia.invalidateSession(session.id);

  const sessionCookie = lucia.createBlankSessionCookie();
  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );
  return revalidatePath("/");
}



Update page.tsx

Update the home page to have some UI shown. With this page.tsx update you will be able to send login and register requests. Also, there is a mini table that shows a list of all registered users.

app/page.tsx
import {
  loginAction,
  logoutAction,
  registerAction,
} from "@/actions/auth-action";
import { db } from "@/lib/database";
import React from "react";
import { validateRequest } from "@/lib/auth/auth";

export default async function Home() {
  const users = await db.query.user.findMany();
  const { user } = await validateRequest();
  return (
    <div className="p-10 flex flex-col gap-4 h-[100vh] overflow-hidden">
      <div className="flex gap-4">
        <div className="flex flex-col w-[500px] gap-4">
          <form
            className="flex flex-col gap-2 border border-neutral-900 max-w-[500px] rounded-md py-6 px-12 bg-slate-50"
            action={registerAction}
          >
            <h1 className="mb-3 text-lg font-bold">Register</h1>
            <div>
              <label className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
                Username
              </label>
              <input
                type="text"
                name="user"
                id="user"
                placeholder="John Doe"
                autoComplete="off"
                className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
              />
            </div>
            <div>
              <label className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
                Email
              </label>
              <input
                type="text"
                name="email"
                id="email"
                placeholder="jon@example.com"
                autoComplete="email"
                className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
              />
            </div>
            <div>
              <label className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
                Password
              </label>
              <input
                placeholder="********"
                name="password"
                type="password"
                id="password"
                className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
              />
            </div>

            <button
              type="submit"
              className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
            >
              Submit
            </button>
          </form>
          <form
            className="flex flex-col gap-2 border border-neutral-900 max-w-[500px] rounded-md py-6 px-12 bg-slate-50"
            action={loginAction}
          >
            <h1 className="mb-3 text-lg font-bold">Login</h1>
            <div>
              <label className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
                Email
              </label>
              <input
                type="text"
                name="email"
                id="email"
                placeholder="jon@example.com"
                autoComplete="email"
                className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
              />
            </div>
            <div>
              <label className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
                Password
              </label>
              <input
                placeholder="********"
                name="password"
                type="password"
                id="password"
                className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
              />
            </div>

            <button
              type="submit"
              className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
            >
              Submit
            </button>
          </form>
        </div>
        <div className="flex flex-col gap-4 flex-1">
          <div className="flex bg-gray-50 border rounded-md border-neutral-900 px-12 py-4 h-[80px] justify-between">
            <div className="flex gap-2 items-center">
              <div className="text-lg font-bold">Logged user:</div>
              <div className="text-lg font-bold text-blue-700">
                {user?.email ?? "None"}
              </div>
            </div>
          </div>
          <div className="flex flex-col bg-gray-50 flex-1 border rounded-md border-neutral-900 px-12 py-6">
            <div className="mb-3 text-lg font-bold">User list:</div>
            <div className="grid grid-cols-3 gap-4 h-[500px] overflow-auto">
              <div className="mb-3 text-lg font-bold">ID</div>
              <div className="mb-3 text-lg font-bold">Username</div>
              <div className="mb-3 text-lg font-bold">Email</div>
              {users.map((user) => (
                <React.Fragment key={user.id}>
                  <p>{user.id}</p>
                  <p>{user.userName}</p>
                  <p>{user.email}</p>
                </React.Fragment>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}


Loading comments