Skip to content

User model ​

A "user model" here isn't a class hierarchy - it's a table + helpers: create user, find by email, verify password. Never store plaintext passwords. Ever.

Schema ​

sql
CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  email_verified_at TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

We'll add token tables for verify / reset in later chapters. Start simple.

Hashing with bcrypt ​

bash
npm install bcryptjs
npm install -D @types/bcryptjs

(bcryptjs is pure JS - fewer native build headaches than bcrypt while learning.)

ts
import bcrypt from 'bcryptjs'

const ROUNDS = 10

export async function hashPassword(plain: string) {
  return bcrypt.hash(plain, ROUNDS)
}

export async function verifyPassword(plain: string, passwordHash: string) {
  return bcrypt.compare(plain, passwordHash)
}

Comments for the code above:

  • Hash is one-way - you can't "decrypt" a password
  • compare is timing-safe enough for this purpose
  • Cost factor 10 is a reasonable default for tutorials; production may tune higher

Helpers ​

ts
export type User = {
  id: number
  email: string
  password_hash: string
  email_verified_at: string | null
  created_at: string
}

export async function createUser(email: string, password: string) {
  const password_hash = await hashPassword(password)
  try {
    const info = db
      .prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)')
      .run(email.toLowerCase(), password_hash)
    return findUserById(Number(info.lastInsertRowid))
  } catch (err: any) {
    if (String(err?.message).includes('UNIQUE')) {
      throw new HttpError(409, 'email already registered')
    }
    throw err
  }
}

export function findUserByEmail(email: string) {
  return db
    .prepare('SELECT * FROM users WHERE email = ?')
    .get(email.toLowerCase()) as User | undefined
}

export function findUserById(id: number) {
  return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined
}

export function publicUser(user: User) {
  return {
    id: user.id,
    email: user.email,
    emailVerified: Boolean(user.email_verified_at),
  }
}

Never send password_hash to the client. publicUser is the API shape.

FE analogy ​

ts
// what you might keep in a Pinia/React store after login
{ id: 1, email: 'ada@example.com', emailVerified: false }

The hash stays in the DB. The SPA only sees the public profile (+ a JWT separately).

Password rules (minimal) ​

Even for a tutorial, reject empty / tiny passwords at the edge:

ts
if (password.length < 8) throw new HttpError(400, 'password too short')

Zod can own this later in Security basics.

What we haven't done yet ​

  • Login endpoint
  • JWT issuance
  • Email verification columns in use
  • Reset tokens

That's the next chapter - register & login against this table.

The companion app mirrors these helpers in examples/notes-api/src/db.ts.

Best,

-- ł.

Notes-style Express tutorial for front-end folks