Password basics
This page covers how to implement a password-based auth with Lucia. If you're looking for a step-by-step, framework-specific tutorial, you may want to check out the Username and password tutorial. Keep in mind that email-based auth requires more than just passwords!
Update database
Add a unique email and hashed_password column to the user table.
| column | type | attributes |
|---|---|---|
email |
string |
unique |
hashed_password |
string |
Declare the type with DatabaseUserAttributes and add the attributes to the user object using the getUserAttributes() configuration.
// auth.ts
import { Lucia } from "lucia";
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: env === "PRODUCTION" // set `Secure` flag in HTTPS
}
},
getUserAttributes: (attributes) => {
return {
// we don't need to expose the hashed password!
email: attributes.email
};
}
});
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
}
interface DatabaseUserAttributes {
email: string;
}
}
Email check
Before creating routes, create a basic utility to verify emails. Emails are notoriously complicated, so here we're just checking if an @ exists with at least 1 character on each side. We just need to check for obvious typos here. For verifying emails, see the email verification page.
export function isValidEmail(email: string): boolean {
return /.+@.+/.test(email);
}
Register user
Create a /signup route. This will accept POST requests with an email and password. Hash the password, create a new user, and create a new session.
import { lucia } from "./auth.js";
import { generateId } from "lucia";
import { Argon2id } from "oslo/password";
app.post("/signup", async (request: Request) => {
const formData = await request.formData();
const email = formData.get("email");
if (!email || typeof email !== "string" || !isValidEmail(email)) {
return new Response("Invalid email", {
status: 400
});
}
const password = formData.get("password");
if (!password || typeof password !== "string" || password.length < 6) {
return new Response("Invalid password", {
status: 400
});
}
const hashedPassword = await new Argon2id().hash(password);
const userId = generateId(15);
try {
await db.table("user").insert({
id: userId,
email,
hashed_password: hashedPassword
});
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
return new Response(null, {
status: 302,
headers: {
Location: "/",
"Set-Cookie": sessionCookie.serialize()
}
});
} catch {
// db error, email taken, etc
return new Response("Email already used", {
status: 400
});
}
});
Hashing passwords
oslo/password currently provides Argon2id, Scrypt, and Bcrypt. These rely on the fastest available libraries but only work in Node.js. Passwords are salted and hashed using settings recommended by OWASP.
import { Argon2id, Scrypt, Bcrypt } from "oslo/password";
For Bun, we recommend using Bun.password, which also uses Argon2id by default. For other runtimes, Lucia provides a pure-JS implementation of Scrypt with Scrypt that works in any environment. However, we do not recommend this for Node.js as it can be 2~3 times slower than the Node-only version. If you're migrating from Lucia v2, you should use LegacyScrypt.
import { Scrypt, LegacyScrypt } from "lucia";
Sign in user
Create a /login route. This will accept POST requests with an email and password. Get the user with the email, verify the password against the hash, and create a new session.
import { lucia } from "./auth.js";
import { generateId } from "lucia";
import { Argon2id } from "oslo/password";
app.post("/login", async (request: Request) => {
const formData = await request.formData();
const email = formData.get("email");
if (!email || typeof email !== "string") {
return new Response("Invalid email", {
status: 400
});
}
const password = formData.get("password");
if (!password || typeof password !== "string") {
return new Response(null, {
status: 400
});
}
const user = await db.table("user").where("email", "=", email).get();
if (!user) {
// NOTE:
// Returning immediately allows malicious actors to figure out valid emails 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 emails.
// However, valid emails can be already be revealed with the signup page
// and a similar timing issue can likely be found in password reset implementation.
// It will also be much more resource intensive.
// Since protecting against this is none-trivial,
// it is crucial your implementation is protected against brute-force attacks with login throttling etc.
// If emails/usernames are public, you may outright tell the user that the username is invalid.
return new Response("Invalid email or password", {
status: 400
});
}
const validPassword = await new Argon2id().verify(user.hashed_password, password);
if (!validPassword) {
return new Response("Invalid email or password", {
status: 400
});
}
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
return new Response(null, {
status: 302,
headers: {
Location: "/",
"Set-Cookie": sessionCookie.serialize()
}
});
});