Build the signup half of a login system
Email and password. From the form to the database, with the hashing your future users will thank you for.
A signup endpoint that accepts an email and password, hashes the password with bcrypt, and writes a row to a users table.
Why you never store raw passwords, what a hashing function actually does, and the rhythm of a server-side write.
Prerequisites
A Node project with a database connection (Postgres works; SQLite is fine). Comfort writing async functions.
Auth is the part of the stack everyone uses and almost nobody has written. That is fine, libraries exist. But writing it once teaches you something the libraries hide: the moves you are trusting them to make on your behalf.
Step 18 min
A users table
GoalA migration that creates a `users` table with id, email, password_hash. Nothing more.
create table users ( id bigserial primary key, email varchar(320) not null unique, password_hash text not null, created_at timestamptz not null default now());Step 210 min
Hash a password
GoalA function that takes a plain password and returns a hash. We will use bcrypt.
Password hashing
A hash function maps a password to a fixed-size string that cannot be reversed back to the password. We store the hash; if our DB leaks, the attacker still does not have passwords.
import bcrypt from 'bcrypt';const hash = await bcrypt.hash('correct horse battery staple', 12);$pnpm add bcrypt+ bcrypt@5.x — added
import bcrypt from 'bcrypt'; const COST = 12; export function hashPassword(plain: string): Promise<string> { return bcrypt.hash(plain, COST);} export function verifyPassword(plain: string, hash: string): Promise<boolean> { return bcrypt.compare(plain, hash);}Step 315 min
The signup endpoint
GoalPOST /auth/signup accepts { email, password }, hashes the password, inserts the user, returns the new id.
import { db } from '../db';import { hashPassword } from './passwords'; interface SignupInput { email: string; password: string; } export async function signup(input: SignupInput) { if (!input.email.includes('@') || input.password.length < 8) { return { ok: false, error: 'invalid input' }; } const passwordHash = await hashPassword(input.password); try { const [row] = await db.insert('users', { email: input.email.toLowerCase(), password_hash: passwordHash, }).returning(['id']); return { ok: true, id: row.id }; } catch (err) { if (isUniqueViolation(err)) return { ok: false, error: 'email taken' }; throw err; }}Verify it works
Milestone
You wrote real auth.
You now know what `bcrypt.hash` does, why we lowercase the email, why the unique-violation check exists, and how the response shape protects callers from secrets. That knowledge survives every library you use later.
Concepts covered · 3
More like this
Make a page respond: a click counter
A page becomes alive the moment JavaScript listens for an event and changes the page in response. Build a counter, then open it in /code.
Build interaction with AI
The vibe-coder path for JS events: the event→state→render loop you must hold in your head, the prompt, and the review.
Build a script with AI
The vibe-coder path for Python: the shape of a small program, the prompt, and the edge cases AI forgets.
Liked this one?
Pass it on
Discussion
Be the first to ask
Your questions stay private with the team. We can pin answers to share with everyone.
Sign in to ask a question privately on this tutorial.
Sign inNo pinned answers yet for this tutorial.