

Authentication in monorepo(NextJs, Astro) with Lucia and MongoDB
This guide will walk you through setting up a simple authentication in a monorepo environment. It covers the common scenario when multiple applications (e.g. landing page and web app), built with different frameworks need to share the same authentication mechanism.
- Create a monorepo mockup (with turborepo)
- Create a shared package to work with MongoDB database (with mongoose)
- Create a shared package to manage auth across monorepo (with lucia-auth)
- Set up user validation in Astro.js
- Set up user validation in Next.js
For all NPM packages, I explicitly specified the latest versions by the moment of writing (instead of @latest
) so this guide can be reproduced in a future. It is recommended to use @latest
version of packages since they should be more secure and stable.
Project overview
-
mysite.com
– landing page built with Astro Publicly available Provides login/signup page Redirects authenticated users toapp.mysite.com
-
app.mysite.com
– web application built with NextJs (app Router) Available only for authenticated users Provides sign-out feature Redirects unauthenticated users tomysite.com
Stack
- Astro js
- Next.js (app router)
- Lucia-auth
- Mongoose
- TurboRepo
- npm
- dotenv
Source code
GitHub - skorphil/monorepo-auth
Prerequisites
- MongoDB atlas(free account will do)
Part 1. Create monorepo mockup
For simplicity starter packages of TurboRepo(with NextJs) and Astro will be used.
Monorepo structure
db-utils
- provides simple db methods to work with MongoDB:createUser()
,getUser()
. These methods are used byauth-utils
.auth-utils
- provides methods to create users and user sessions. Used byweb
andlanding
web
- web application, accessible only for authenticated users. Provides log-out functionlanding
- public landing page. Provides logout and login form. Inaccessible for authenticated users
Install Turborepo
Install Turborepo starter package:
# ? Where would you like to create your turborepo? ./monorepo-auth# ? Which package manager do you want to use? npm workspaces
Create landing page (@monorepo-auth/landing)
Install Astro starter package inside {monorepo}/apps/landing
# Where should we create your new project? ./apps/landing# How would you like to start your new project? Include sample files# Do you plan to write TypeScript? Yes# How strict should TypeScript be? Strict# Install dependencies? Yes# Initialize a new git repository? No
Rename the package to maintain consistency:
"name": "monorepo-auth-apps-landing", "name": "@monorepo-auth/landing",
Create web app (@monorepo-auth/web)
Next.js starter package is already being created with a turborepo, so just rename it:
"name": "web", "name": "@monorepo-auth/web",
Delete {monorepo}/apps/docs
package, so there is only 2 packages left in apps
directory:
# Monorepo structure so far
monorepo-auth/└── apps/ ├── web # @monorepo-auth/web └── landing # @monorepo-auth/landing
Test run npm run dev
to make sure everything works as expected. In my case landing
runs at localhost:4321
and web
runs at localhost:3000
.
If everything is working it’s time to set up an authentication.
Part 2. Create database utilities (@monorepo-auth/db-utils)
Database methods are usually used among multiple packages inside the project, this is why it is better to create them in a separate package. Only a few methods are needed for now: createUser()
method for the sign-up form and getUser()
for the login form. Also, lucia mongodb adapter
needs dbConnect()
method.
Create a db-utils
package. I created it in {monorepo}/packages
mkdir packages/db-utils && touch packages/db-utils/package.json && touch packages/db-utils/.env
Get connection string(URI) for your ModgoDB Atlas: Connection Strings - MongoDB Manual v7.0
Add URI to the created .env
file.
MONGO_URI="mongodb_uri_here"
Set up Turborepo to use created .env
. I used dotenv-cli
to make global .env
file accessible by all packages. Install it to the monorepo root:
Add globalDotEnv
to turbo.json
config:
{ "$schema": "https://turbo.build/schema.json", "globalDependencies": ["**/.env.*local"], "globalDotEnv": [".env"],
Edit global package.json
to run turbo
with dotenv
"scripts": { "build": "turbo build", "dev": "dotenv -- turbo dev",
Continue creating db-utils. Edit db-utils package.json
:
{ "name": "@monorepo-auth/db-utils", "type": "module", "exports": "./index.js", "version": "0.0.1"}
Install necessary packages to @monorepo-auth/db-utils
Create dbConnect()
method is used to connect to a specified mongo database.
import { connect } from "mongoose";
export async function dbConnect() { try { await connect(process.env.MONGO_URI); console.debug("Database connected"); } catch (error) { throw error; }}
Create User
and Session
models.
I followed recommendations from Lucia docs and expanded userSchema
to include username
and hashed_password
along with _id
:
import { Schema, model, models } from "mongoose";
const userSchema = new Schema( { _id: { type: String, required: true, }, username: { type: String, required: true, }, password_hash: { type: String, required: true, }, }, { _id: false } // default mongodb _id will be replaced by custom _id, which is being generated from entropy as Lucia docs suggesting);
export default models.User || model("User", userSchema);
import { Schema, model, models } from "mongoose";
const sessionSchema = new Schema( { _id: { type: String, required: true, }, user_id: { type: String, required: true, }, expires_at: { type: Date, required: true, }, }, { _id: false });
export default models.Record || model("Session", sessionSchema);
Create createUser()
and getUser()
methods.
import { dbConnect } from "./dbConnect";import User from "../models/user.model";
export async function createUser(userData) { const user = await new User(userData); try { await dbConnect(); await user.save(); console.debug("User saved to db"); } catch (error) { throw error; }}
import User from "../models/user.model";
export async function getUser(userData) { const user = await User.findOne(userData, { _id: 1, password_hash: 1, username: 1, }); if (user) { return user; } else return false;}
Create Lucia adapter
import { dbConnect } from "./dbConnect";import { MongodbAdapter } from "@lucia-auth/adapter-mongodb";import mongoose from "mongoose";
await dbConnect();
export const adapter = new MongodbAdapter( mongoose.connection.collection("sessions"), mongoose.connection.collection("users"));
Create interface for db-utils
To export created methods, create index.js
in the root of db-utils
package:
import { dbConnect } from "./lib/dbConnect";import { createUser } from "./lib/createUser";import { getUser } from "./lib/checkUser";import { adapter } from "./lib/adapter";
export { createUser, adapter, dbConnect, getUser };
db-utils
package ready and can be used by auth-utils
.
# db-utils package structure
db-utils/├── lib/│ ├── dbConnect.js│ ├── createUser.js│ └── getUser.js├── models/│ ├── session.model.js│ └── user.model.js├── package.json└── index.js
Part 3. Setup Lucia-auth (@monorepo-auth/auth-utils)
Since both apps will use auth, it is better to define auth methods in a separate package.
Create an auth-utils
package. I created it in {monorepo}/packages
:
mkdir packages/auth-utils && touch packages/auth-utils/package.json && touch packages/auth-utils/tsconfig.json
Edit created package.json
and tsconfig.json
{ "name": "@monorepo-auth/auth-utils", "type": "module", "exports": "./index.js", "version": "0.0.1"}
{ "compilerOptions": { "noImplicitAny": false, // i specified this to allow imports of undeclared js modules (db-utils) "module": "ESNext", "target": "ESNext", "moduleResolution":"Bundler" }}
Install necessary packages to @monorepo-auth/auth-utils
Create lucia
module
I’ve followed Lucia docs here, performing some decomposition.
import { adapter } from "@monorepo-auth/db-utils";import { Lucia } from "lucia";
export const lucia = new Lucia(adapter, { sessionCookie: { attributes: { secure: /* import.meta.env.PROD */ false, }, }, getUserAttributes: (attributes) => { return { username: attributes.username, }; },});
declare module "lucia" { interface Register { Lucia: typeof lucia; DatabaseUserAttributes: DatabaseUserAttributes; }}
interface DatabaseUserAttributes { username: string;}
Create auth-utils interface
There is only a single export needed so far.
export { lucia } from "./auth";
auth-utils
package is ready and it is time to implement auth in web
and landing
packages.
# auth-utils package structure
auth-utils/├── tsconfig.json├── package.json├── index.ts└── auth.ts
Part 4. Implement auth in @monorepo-auth/landing
Create middleware
Astro middleware use lucia to manage user sessions. It defines session
and user
in context.locals
making it accessible by other parts of an app.
import { lucia, verifyRequestOrigin } from "@monorepo-auth/auth-utils";import { defineMiddleware } from "astro:middleware";
export const onRequest = defineMiddleware(async (context, next) => { if (context.request.method !== "GET") { const originHeader = context.request.headers.get("Origin"); const hostHeader = context.request.headers.get("Host"); if ( !originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader]) ) { return new Response(null, { status: 403, }); } }
const sessionId = context.cookies.get(lucia.sessionCookieName)?.value ?? null; if (!sessionId) { context.locals.user = null; context.locals.session = null; return next(); }
const { session, user } = await lucia.validateSession(sessionId); if (session && session.fresh) { const sessionCookie = lucia.createSessionCookie(session.id); context.cookies.set( sessionCookie.name, sessionCookie.value, sessionCookie.attributes ); } if (!session) { const sessionCookie = lucia.createBlankSessionCookie(); context.cookies.set( sessionCookie.name, sessionCookie.value, sessionCookie.attributes ); } context.locals.session = session; context.locals.user = user; return next();});
Declare session
and user
types
/// <reference types="astro/client" />
declare namespace App { interface Locals { session: import("lucia").Session | null; user: import("lucia").User | null; }}
Lucia works only in Astro server mode, so edit astro.config.mjs
:
import { defineConfig } from "astro/config";import node from "@astrojs/node";
export default defineConfig({ output: "server", adapter: node({ mode: "standalone", }),});
Enabling server mode requires to install @astrojs/node
adapter
npm install @astrojs/[email protected] --workspace="@monorepo-auth/landing"
Create signup form and API
I strictly followed lucia docs to make it more simple, so I created login and signup pages in landing package. However, to achieve modular and flexible architecture they can be created as a part of separate auth package with respective redirects.
API and signup form are copies from lucia docs, but imports shared db-utils
and auth-utils
:
import { lucia } from "@monorepo-auth/auth-utils";import { createUser } from "@monorepo-auth/db-utils";
import { hash } from "@node-rs/argon2";import { generateIdFromEntropySize } from "lucia";
import type { APIContext } from "astro";
export async function POST(context: APIContext): Promise<Response> { const formData = await context.request.formData(); const username = formData.get("username"); // username must be between 4 ~ 31 characters, and only consists of lowercase letters, 0-9, -, and _ // keep in mind some database (e.g. mysql) are case insensitive if ( typeof username !== "string" || username.length < 3 || username.length > 31 || !/^[a-z0-9_-]+$/.test(username) ) { return new Response("Invalid username", { status: 400, }); } const password = formData.get("password"); if ( typeof password !== "string" || password.length < 6 || password.length > 255 ) { return new Response("Invalid password", { status: 400, }); }
const userId = generateIdFromEntropySize(10); // 16 characters long const passwordHash = await hash(password, { // recommended minimum parameters memoryCost: 19456, timeCost: 2, outputLen: 32, parallelism: 1, });
// TODO: check if username is already used await createUser({ _id: userId, username: username, password_hash: passwordHash, });
const session = await lucia.createSession(userId, {}); const sessionCookie = lucia.createSessionCookie(session.id); context.cookies.set( sessionCookie.name, sessionCookie.value, sessionCookie.attributes );
return context.redirect("/");}
Create signup form:
<html lang="en"> <body> <h1>Signup Page</h1> <form method="post" action="/api/signup"> <label for="username">Username</label> <input id="username" name="username" /> <label for="password">Password</label> <input id="password" name="password" /> <button>Continue</button> </form> </body></html>
Add signup form link to index.astro
to simplify navigation. I deleted original content of index.astro
to make it simpler:
<Layout title="Welcome to Astro."> <main> <h1>Landing page</h1> <a href="/signup">Signup</a> </main></Layout>
To check if sign up feature is working:
- Launch project
npm run dev
- Create new user on
http://localhost:4321/signup
In MongoDB atlas there should be a new user inusers
collection as well as a corresponding session insessions
collection.
In browser there should be auth_session
cookie
Create login form and API
import { lucia } from "@monorepo-auth/auth-utils";import { getUser } from "@monorepo-auth/db-utils";
import { verify } from "@node-rs/argon2";import type { APIContext } from "astro";
interface UserDocument extends Document { _id: string; username: string; password_hash: string;}
export async function POST(context: APIContext): Promise<Response> { const formData = await context.request.formData(); const username = formData.get("username"); if ( typeof username !== "string" || username.length < 3 || username.length > 31 || !/^[a-z0-9_-]+$/.test(username) ) { return new Response("Invalid username", { status: 400, }); } const password = formData.get("password"); if ( typeof password !== "string" || password.length < 6 || password.length > 255 ) { return new Response("Invalid password", { status: 400, }); }
const existingUser = await getUser({ username: username }); console.log(existingUser); if (!existingUser) { // NOTE: // Returning immediately allows malicious actors to figure out valid usernames from response times, // allowing them to only focus on guessing passwords in brute-force attacks. // As a preventive measure, you may want to hash passwords even for invalid usernames. // However, valid usernames can be already be revealed with the signup page among other methods. // It will also be much more resource intensive. // Since protecting against this is non-trivial, // it is crucial your implementation is protected against brute-force attacks with login throttling etc. // If usernames are public, you may outright tell the user that the username is invalid. return new Response("Incorrect username or password", { status: 400, }); }
const validPassword = await verify(existingUser.password_hash, password, { memoryCost: 19456, timeCost: 2, outputLen: 32, parallelism: 1, }); if (!validPassword) { return new Response("Incorrect username or password", { status: 400, }); }
const session = await lucia.createSession(existingUser._id, {}); const sessionCookie = lucia.createSessionCookie(session.id); context.cookies.set( sessionCookie.name, sessionCookie.value, sessionCookie.attributes );
return context.redirect("/");}
<html lang="en"> <body> <h1>Login Page</h1> <form method="post" action="/api/login"> <label for="username">Username</label> <input id="username" name="username" /> <label for="password">Password</label> <input id="password" name="password" /> <button>Continue</button> </form> </body></html>
Add login form link to index.astro
:
<Layout title="Welcome to Astro."> <main> <h1>Landing page</h1> <a href="/signup">Signup</a> <a href="/login">Login</a> </main></Layout>
Redirect authenticated user to web app
For convenience create environment variables in root .env
file with urls on which they run. In my case:
MONGO_URI="mongodb_uri_here" WEB_URL="http://localhost:3000" LANDING_URL="http://localhost:4321"
After middleware created user in context.locals
, it can be checked in astro pages within frontmatter:
---const user = Astro.locals.user;if (user) { return Astro.redirect(process.env.WEB_URL);}---
Now if the user is authenticated it will be redirected to web
.
Part 5. Implement auth in @monorepo-auth/web
The last part of this guide covers setting up web
package to redirect unauthenticated users to the landing page and provide log-out feature.
Validate users in server components
Create validateRequest()
function in auth.ts
. It is a copy from Lucia documentation with a different lucia
import.
import { cookies } from "next/headers";import { cache } from "react";import { lucia } from "@monorepo-auth/auth-utils"; // lucia instance from shared auth-utils
import type { Session, User } from "lucia";
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; });
validateRequest()
can be used on server components to check if a user is authenticated. Setting up validation in client component requires setting up API or context, which is not covered in this guide.
Add redirect to landing for unauthenticated users:
import { validateRequest } from "../utils/auth";import type { ActionResult } from "next/dist/server/app-render/types";import { redirect } from "next/navigation"
export default async function ProtectedPage() { const { user } = await validateRequest(); if (!user) { return redirect(process.env.LANDING_URL); } return ( <> <h1>Web-app</h1> <h2>Hi, {user.username}!</h2> </> );}
Create logout button in Next.js
Since authenticated users don’t have access to landing page (it redirects them to web
), logout feature should be implemented in web
package:
import { validateRequest } from "../utils/auth";import { lucia } from "@monorepo-auth/auth-utils";import { cookies } from "next/headers";import { redirect } from "next/navigation";
import type { ActionResult } from "next/dist/server/app-render/types";
export default async function ProtectedPage() { const { user } = await validateRequest(); if (!user) { return redirect(process.env.LANDING_URL as string); } return ( <> <h1>Web-app</h1> <h2>Hi, {user.username}!</h2> <form action={logout}> <button>Sign out</button> </form> </> );}
async function logout(): Promise<ActionResult> { "use server"; const { session } = await validateRequest(); if (!session) { return { error: "Unauthorized", }; }
await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie(); cookies().set( sessionCookie.name, sessionCookie.value, sessionCookie.attributes ); return redirect(process.env.LANDING_URL as string);}
Outcome
- Both packages in a monorepo can access user session and validate if user is authenticated.
db-utils
andauth-utils
can be used by other packages that might be added to monorepo in the future.- project source code: GitHub - skorphil/monorepo-auth
Further reading:
- Lucia documentation
- Building Your Application: Authentication | Next.js
- Authentication | Astro Docs
- The Copenhagen Book
- Mongoose v8.4.1: Getting Started
Happy coding! Feedback is appreciated.
← Back to home