๐Ÿ” Build a Login & Database System

Zero experience required. By the end of this guide you will have built, from scratch, a working login system with password authentication and a real SQLite database โ€” the same kind behind the Auth Lab demo on this site. Every file is included in full, ready to copy.

1. What are we building?

A small web app with the three things every big system has: a frontend (forms), a server (a Netlify function that handles login, sessions, and protected data), and a database (SQLite, a real file-based SQL database). You create an account, log in, see a protected member area, and keep private notes โ€” all of it per-user.

๐ŸŽฏ Features of the finished lab

FeatureBuilt with
Create an accountRegister form + scrypt password hashing
Log in / log outSession tokens stored in SQLite + signed cookies
Stay signed inA cookie the browser sends on every request
Protected pagesServer checks your session before showing anything
Per-user private notesEach note row belongs to exactly one user
Real databaseSQLite โ€” three tables, prepared statements
๐Ÿจ Analogy โ€” A Hotel with a Front Desk
The front desk (the server) never tells guests where the safe key is. You check in (register), the desk verifies who you are (password check), gives you a wristband (session cookie), and you flash the wristband every time you order room service (protected requests). Your room key (password) can change, and the wristband can be revoked โ€” they are two different things.

2. Why this stack? (The Benefits)

๐Ÿ”“ Hand-rolled auth
No auth framework: you write the hashing, the sessions, and the guards yourself โ€” which is exactly how you learn each block.
๐Ÿ—„ Real SQL
SQLite is a genuine database with tables, queries, and prepared statements โ€” not a toy.
๐Ÿ’ธ Still 100% free
Netlify free tier hosts functions and static files; SQLite and Node's crypto are built-in.
๐Ÿง  Easy to inspect
No magic. Open any file and read exactly what happens on register, login, or delete.
๐Ÿš€ Upgradeable
Swap SQLite for Postgres or sessions for JWT later โ€” the blocks keep the same shape.
๐Ÿงฑ The 10 blocks
Form, fetch, rewrite, router, validation, hashing, database, session, cookie, guard โ€” every one practiced here.

3. How it works (Architecture)

Every request travels the same road. This is the whole system in one picture:

๐Ÿงญ Register / Login round trip

1. Browser posts the form as JSON
fetch('/api/register', { method: 'POST', body: JSON.stringify({ username, password }) })

2. netlify.toml rewrites the URL to our function
/api/register  ->  /.netlify/functions/api?action=register

3. The function validates, hashes the password, and inserts a user row
INSERT INTO users (username, pass_hash) VALUES (?, ?)   -- scrypt hash, never plaintext

4. A session row is created and handed to the browser as a cookie
Set-Cookie: sid=<random-token>.<signature>; HttpOnly; SameSite=Lax; Max-Age=604800

5. The browser now sends the cookie with EVERY request โ€” that is "being logged in"
GET /api/me  (with Cookie: sid=...)  ->  { "user": { "username": "alice" } }
๐ŸŽŸ Analogy โ€” The Concert Wristband
Your session cookie is a wristband: it proves you already paid, you can't read the VIP area with it, and the venue checks it at every door. The signature is like the venue's hologram โ€” only the venue can make one, so a fake wristband is spotted instantly. If the venue cancels the band (logout), the row in its book (the sessions table) disappears too.

5. Anatomy of the Project (The Files)

๐Ÿ“ Your project folder at a glance

your-site/
โ”œโ”€โ”€ index.html                  (already there - your portfolio)
โ”œโ”€โ”€ auth-lab/
โ”‚   โ”œโ”€โ”€ index.html              (the login page)
โ”‚   โ”œโ”€โ”€ auth.js                 (client logic)
โ”‚   โ””โ”€โ”€ styles.css              (demo styling)
โ”œโ”€โ”€ netlify/
โ”‚   โ”œโ”€โ”€ api.js                  (the server: router + actions)
โ”‚   โ””โ”€โ”€ lib/
โ”‚       โ”œโ”€โ”€ crypto.js           (hashing + signing)
โ”‚       โ””โ”€โ”€ db.js               (SQLite + storage adapter)
โ”œโ”€โ”€ netlify.toml                (enables functions + /api/* rewrite)
โ””โ”€โ”€ package.json                (better-sqlite3, @netlify/blobs)
FileWhat it does
auth-lab/index.htmlThe page: login/register forms + the member area
auth-lab/auth.jsClient logic: talks to the API, renders the member area
auth-lab/styles.cssVisuals for the demo
netlify/functions/api.jsThe server: router + all auth actions
netlify/functions/lib/crypto.jsPassword hashing and cookie signatures
netlify/functions/lib/db.jsSQLite setup + the persistence adapter
netlify.tomlEnables functions and rewrites /api/* to them
package.jsonThe two dependencies: better-sqlite3 and @netlify/blobs

๐Ÿ”Œ The API surface (all endpoints)

EndpointMethodWhat it does
/api/registerPOSTCreate a user (validates, hashes, logs you in)
/api/loginPOSTVerify password, mint a session cookie
/api/logoutPOSTDelete the session row, clear the cookie
/api/meGETWho am I? (protected โ€” 401 without a session)
/api/notesGET / POSTList or add my notes (protected)
/api/notes/<id>DELETEDelete one of my notes โ€” never someone else's

Everything is plain HTML, CSS, and vanilla JavaScript plus one Node module set. The server code is a single Netlify function with a tiny router inside โ€” one entry point, many actions.

6. Before you start (Tools & Accounts)

Grab the free tools

You need Node.js (nodejs.org, the LTS button) and any code editor โ€” VS Code is the friendliest for beginners.

One account: Netlify

This project needs functions and a storage service, and Netlify provides both free. Sign up at netlify.com. No API keys from third parties are needed this time.

Understand what you will do

Add 8 files to your existing site folder, install two npm packages, set one secret, and deploy. The trickiest part is reading the code โ€” the steps below explain every block.

7. Step-by-Step: From Zero to a Login System

Create the files

Inside your site folder create these files/folders. Every file is shown in full below โ€” paste the code and save with the exact names.

mkdir -p auth-lab netlify/functions/lib

Files: auth-lab/index.html, auth-lab/auth.js, auth-lab/styles.css, netlify/functions/api.js, netlify/functions/lib/crypto.js, netlify/functions/lib/db.js, plus the two root files netlify.toml and package.json.

Install the two dependencies

The server needs the SQLite driver and Netlify's storage client. Run this in your site folder:

npm install

That reads package.json and installs better-sqlite3 (the SQL database driver) and @netlify/blobs (storage for the database file). It downloads binaries for your machine automatically.

Set the signing secret (the security gate)

Session cookies are signed with HMAC using one secret. Generate a strong one:

openssl rand -hex 32

Keep the output โ€” it is your AUTH_LAB_SECRET. Locally, you can run the dev server with it in an environment variable; on Netlify you add it in the dashboard: Site settings โ†’ Environment variables โ†’ Add a variable, name AUTH_LAB_SECRET, value the hex string. The code has a clearly-marked fallback for local testing only โ€” never leave the fallback in production.

Run it locally

Netlify's dev server serves the pages and runs the function exactly like production:

npx netlify dev

Open http://localhost:8888/auth-lab/. Create an account, log out, log back in, add and delete a note. Open the browser's Developer Tools โ†’ Network and look at the Set-Cookie header after registering โ€” that moment is the whole project in one line.

Make a backup of the database to understand it

While the dev server is off, open the SQLite file to see what the server really stored โ€” the hashed password is not your password:

node -e "const D=require('better-sqlite3');const db=new D('data/auth-lab.db');console.log(db.prepare('SELECT sql FROM sqlite_master WHERE sql IS NOT NULL').all().map(r=>r.sql).join('\n\n'))"

You will see the three tables and, in pass_hash, the scrypt:salt:hash strings. Type your password in the browser's console through the crypto module and compare โ€” the database never keeps it.

Deploy

One command ships everything โ€” pages, function, and config:

npx netlify deploy --prod --dir=.

On the deployed site, requests with live blob storage put the database file in Netlify Blobs โ€” a key-value store that survives redeploys โ€” so users you register stay registered. That is the last block: durable data.

7.1 The Files, Line by Line

Here is every file in full. They are the exact files behind the live Auth Lab demo.

โ›“ netlify.toml โ€” functions + the URL rewrite

๐Ÿ“„ netlify.toml (project root)

[build]
  publish = "."
  functions = "netlify/functions"

[[redirects]]
  from = "/api/*"
  to = "/.netlify/functions/api?action=:splat"
  status = 200

๐Ÿ“ฆ package.json โ€” the dependency manifest

๐Ÿ“„ package.json (project root)

{
  "name": "learn-more",
  "version": "1.0.0",
  "private": true,
  "description": "Portfolio site with the auth-lab playground",
  "dependencies": {
    "@netlify/blobs": "^8.1.0",
    "better-sqlite3": "^11.9.1"
  }
}

๐Ÿ”‘ lib/crypto.js โ€” hashing & signing

๐Ÿ“„ netlify/functions/lib/crypto.js

Node ships crypto built in โ€” zero third-party security code. scryptSync is a deliberately slow hash: a stolen hash can be brute-forced, but slow = too expensive. Salt makes two users with the same password store different hashes. timingSafeEqual compares in constant time so attackers cannot measure byte-by-byte differences. sign/unsign make the HMAC fingerprint: the browser receives token.signature, and if anyone edits the token the signature no longer matches.

// crypto.js โ€” the security block.
// Everything here uses Node's built-in crypto module โ€” no third-party code.

const crypto = require('crypto')

// HMAC key used to sign session ids. Set AUTH_LAB_SECRET in Netlify's
// environment variables (Netlify dashboard -> Site settings ->
// Environment variables). The fallback is ONLY for local testing.
const SECRET = process.env.AUTH_LAB_SECRET || 'dev-only-secret-change-me'

// Hash a password with scrypt + a random salt.
// Stored as "scrypt:<salt>:<hash>" so the salt and algorithm travel with it.
function hashPassword(password) {
  const salt = crypto.randomBytes(16).toString('hex')
  const hash = crypto.scryptSync(password, salt, 64).toString('hex')
  return `scrypt:${salt}:${hash}`
}

// Check a password against a stored "scrypt:<salt>:<hash>" string.
// timingSafeEqual compares in constant time so a fast machine can't guess
// the hash byte-by-byte.
function verifyPassword(password, stored) {
  const [scheme, salt, hash] = stored.split(':')
  if (scheme !== 'scrypt') return false
  const candidate = crypto.scryptSync(password, salt, 64)
  const expected = Buffer.from(hash, 'hex')
  return candidate.length === expected.length &&
    crypto.timingSafeEqual(candidate, expected)
}

// HMAC signature for a session id: sig = HMAC(sid, SECRET)
function sign(value) {
  return crypto.createHmac('sha256', SECRET).update(value).digest('hex')
}

// Check a signature in constant time.
function unsign(value, signature) {
  const expected = sign(value)
  const a = Buffer.from(signature, 'hex')
  const b = Buffer.from(expected, 'hex')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

// Opaque random session id โ€” 32 bytes of pure randomness.
function newSessionId() {
  return crypto.randomBytes(32).toString('hex')
}

module.exports = { hashPassword, verifyPassword, sign, unsign, newSessionId }

๐Ÿ—„ lib/db.js โ€” SQLite & the storage adapter

๐Ÿ“„ netlify/functions/lib/db.js

The schema is three tables: users (id, username, pass_hash, created_at), sessions (token โ†’ user), and notes (id, user_id, body). The FOREIGN KEY says a session/note always belongs to a real user. CREATE TABLE IF NOT EXISTS runs on every request so the first request just works. The second half is the storage adapter: Netlify's function drives are temporary, so on Netlify the .db file lives in Blobs โ€” download, query, upload again. Locally it is a plain file on disk. Both return { db, close, persist }, so the rest of the code never cares which mode it is in.

// db.js โ€” the database block.
// A real SQLite database with three tables. Netlify's function drives are
// temporary, so in production the .db file rides on Netlify Blobs (Netlify's
// key-value store): we download it before each request and upload it after.

const fs = require('fs')
const os = require('os')
const path = require('path')
const Database = require('better-sqlite3')
const { getStore } = require('@netlify/blobs')

const BLOB_KEY = 'auth-lab.db'
const LOCAL_PATH = path.join(__dirname, '..', '..', '..', 'data', 'auth-lab.db')
const USE_BLOB = process.env.NETLIFY === 'true' || process.env.AUTH_LAB_USE_BLOB === 'true'

const SCHEMA = `
CREATE TABLE IF NOT EXISTS users (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  username   TEXT NOT NULL UNIQUE,
  pass_hash  TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS sessions (
  id         TEXT PRIMARY KEY,
  user_id    INTEGER NOT NULL REFERENCES users(id),
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS notes (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id    INTEGER NOT NULL REFERENCES users(id),
  body       TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`

function migrate(db) {
  db.pragma('journal_mode = WAL')
  db.exec(SCHEMA)
}

// Local mode: one persistent file on disk. Reuses the connection.
let localDb = null
function getLocalDb() {
  if (localDb) return localDb
  fs.mkdirSync(path.dirname(LOCAL_PATH), { recursive: true })
  localDb = new Database(LOCAL_PATH)
  migrate(localDb)
  return localDb
}

// Netlify mode: fetch the .db file from Blobs, query it, upload it again.
// On a real Netlify deploy the blob credentials are injected automatically โ€”
// no configuration needed. Returns null if the environment has no blob
// credentials (e.g. an unlinked local `netlify dev`), so we can fall back.
async function getBlobDb() {
  let store
  try {
    store = getStore('auth-lab')
  } catch (err) {
    console.log('[auth-lab] blob store unavailable, falling back to file database')
    return null
  }
  let buf = null
  try {
    buf = await store.get(BLOB_KEY, { type: 'arrayBuffer' })
  } catch {
    buf = null // first ever request โ€” no blob yet, start fresh
  }
  const tmp = path.join(os.tmpdir(), 'auth-lab.db')
  if (buf) fs.writeFileSync(tmp, Buffer.from(buf))
  const db = new Database(tmp)
  migrate(db)
  return {
    db,
    close: () => db.close(),
    persist: async () => {
      db.pragma('wal_checkpoint(TRUNCATE)')
      await store.set(BLOB_KEY, fs.readFileSync(tmp))
    },
  }
}

// Always returns { db, close, persist }.
async function initDb() {
  if (USE_BLOB) {
    const blob = await getBlobDb()
    if (blob) return blob
  }
  const db = getLocalDb()
  return { db, close: () => {}, persist: async () => {} }
}

module.exports = { initDb }

โš™๏ธ api.js โ€” the server (router + all actions)

๐Ÿ“„ netlify/functions/api.js

Read the top first: cookie parsing and building are here (parseCookies, sessionCookie). currentUser() is the guard: it unsigns the cookie, looks the token up in sessions, and joins the user row. The switch (action) is the router. Notice the order in withDb: open the database, run, persist, close โ€” every request is a clean transaction. In deleteNote the WHERE ... AND user_id = ? is the ownership rule: even a guessed id cannot touch other users' rows.

// api.js โ€” the server block. One function, many actions.
// netlify.toml rewrites /api/<action> to /.netlify/functions/api?action=<action>.

const { initDb } = require('./lib/db')
const {
  hashPassword, verifyPassword, sign, unsign, newSessionId,
} = require('./lib/crypto')

const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/
const MAX_AGE = 60 * 60 * 24 * 7 // session cookie lives 7 days
const IS_SECURE = process.env.NETLIFY === 'true' // HTTPS only in production

// ---------- cookie helpers ----------
function parseCookies(header = '') {
  const out = {}
  for (const part of header.split(';')) {
    const [name, ...rest] = part.trim().split('=')
    if (name) out[name] = rest.join('=').trim()
  }
  return out
}

function clearCookie(name) {
  return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
}

function sessionCookie(sid) {
  const secure = IS_SECURE ? '; Secure' : ''
  return `sid=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${MAX_AGE}${secure}`
}

// Sessions die after MAX_AGE, both in the browser AND in the database โ€”
// the server-side filter keeps old rows meaningless even if a cookie survives.
const SESSION_AGE = `created_at >= datetime('now', '-${MAX_AGE} seconds')`

// ---------- shared helpers ----------
function json(status, body, extra = {}) {
  return {
    statusCode: status,
    headers: { 'Content-Type': 'application/json', ...extra },
    body: JSON.stringify(body),
  }
}

async function withDb(fn) {
  const { db, close, persist } = await initDb()
  try {
    return await fn(db)
  } finally {
    await persist()
    close()
  }
}

// session -> { id, username, createdAt, sid } or null
function currentUser(db, event) {
  const cookies = parseCookies(event.headers.cookie)
  const [sid, sig] = String(cookies.sid || '').split('.')
  if (!sid || !sig || !unsign(sid, sig)) return null
  const row = db.prepare(`
    SELECT u.id, u.username, u.created_at AS createdAt
    FROM sessions s JOIN users u ON u.id = s.user_id
    WHERE s.id = ? AND s.${SESSION_AGE}`).get(sid)
  return row ? { ...row, sid } : null
}

function newSession(db, userId) {
  const token = newSessionId()
  const sid = `${token}.${sign(token)}` // signature covers THE token
  db.prepare('INSERT INTO sessions (id, user_id) VALUES (?, ?)').run(token, userId)
  return sid
}

// ---------- actions ----------
async function register(db, body) {
  const username = String(body.username || '').trim()
  const password = String(body.password || '')
  if (!USERNAME_RE.test(username)) {
    return json(400, { error: 'Username must be 3-20 characters: letters, numbers, underscore.' })
  }
  if (password.length < 8) {
    return json(400, { error: 'Password must be at least 8 characters.' })
  }
  if (password.length > 128) {
    return json(400, { error: 'Password is too long (max 128 characters).' })
  }
  const taken = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
  if (taken) return json(409, { error: 'That username is already taken.' })

  const info = db.prepare('INSERT INTO users (username, pass_hash) VALUES (?, ?)')
    .run(username, hashPassword(password))
  const user = db.prepare('SELECT id, username, created_at AS createdAt FROM users WHERE id = ?')
    .get(info.lastInsertRowid)
  const sid = newSession(db, user.id)
  return json(201, { user }, { 'Set-Cookie': sessionCookie(sid) })
}

async function login(db, body) {
  const username = String(body.username || '').trim()
  const password = String(body.password || '')
  const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username)
  if (!row || !verifyPassword(password, row.pass_hash)) {
    return json(401, { error: 'Wrong username or password.' })
  }
  const sid = newSession(db, row.id)
  const user = { id: row.id, username: row.username, createdAt: row.created_at }
  return json(200, { user }, { 'Set-Cookie': sessionCookie(sid) })
}

async function logout(db, user) {
  if (user) {
    db.prepare('DELETE FROM sessions WHERE id = ?').run(user.sid)
  }
  return json(200, { ok: true }, { 'Set-Cookie': clearCookie('sid') })
}

async function me(db, user) {
  if (!user) return json(401, { error: 'Not logged in.' })
  const { sid, ...safeUser } = user // the session token stays between client and server
  return json(200, { user: safeUser })
}

async function listNotes(db, user) {
  if (!user) return json(401, { error: 'Not logged in.' })
  const rows = db.prepare(`
    SELECT id, body, created_at AS createdAt FROM notes
    WHERE user_id = ? ORDER BY id DESC`).all(user.id)
  return json(200, { notes: rows })
}

async function addNote(db, user, body) {
  if (!user) return json(401, { error: 'Not logged in.' })
  const text = String(body.body || '').trim()
  if (!text) return json(400, { error: 'Note cannot be empty.' })
  if (text.length > 200) return json(400, { error: 'Note is too long (max 200 characters).' })
  const info = db.prepare('INSERT INTO notes (user_id, body) VALUES (?, ?)').run(user.id, text)
  return json(201, { id: info.lastInsertRowid })
}

async function deleteNote(db, user, id) {
  if (!user) return json(401, { error: 'Not logged in.' })
  // WHERE user_id = ? makes it impossible to delete someone else's note.
  const info = db.prepare('DELETE FROM notes WHERE id = ? AND user_id = ?').run(id, user.id)
  if (info.changes === 0) return json(404, { error: 'Note not found.' })
  return json(200, { ok: true })
}

// ---------- router ----------
exports.handler = async (event) => {
  const url = new URL(event.rawUrl)
  const action = url.searchParams.get('action')
  let body = {}
  try {
    body = event.body ? JSON.parse(event.body) : {}
  } catch {
    return json(400, { error: 'Request body is not valid JSON.' })
  }

  return withDb(async (db) => {
    const user = currentUser(db, event)
    const noteMatch = /^notes\/(\d+)$/.exec(action || '')
    switch (action) {
      case 'register': return register(db, body)
      case 'login':    return login(db, body)
      case 'logout':   return logout(db, user)
      case 'me':       return me(db, user)
      case 'notes':
        if (event.httpMethod === 'GET') return listNotes(db, user)
        if (event.httpMethod === 'POST') return addNote(db, user, body)
        break
      default:
        if (noteMatch && event.httpMethod === 'DELETE') {
          const id = Number(noteMatch[1])
          if (!Number.isSafeInteger(id)) return json(404, { error: 'Note not found.' })
          return deleteNote(db, user, id)
        }
        return json(404, { error: 'Unknown action.' })
    }
  })
}

๐Ÿ“„ auth-lab/index.html โ€” the page shell

๐Ÿ“„ auth-lab/index.html (project root)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Auth Lab - Login &amp; Database Demo</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>

  <header class="lab-top">
    <div class="lab-inner">
      <span class="lab-logo">๐Ÿ” Auth Lab</span>
      <span class="lab-tag">login ยท scrypt ยท SQLite ยท sessions</span>
    </div>
  </header>

  <main class="lab-main">

    <!-- ============ GUEST AREA: login / register ============ -->
    <section id="guest-area" class="panel">
      <div class="panel-head">
        <button id="tab-login" class="tab-btn active" onclick="showTab('login')">Log in</button>
        <button id="tab-register" class="tab-btn" onclick="showTab('register')">Create account</button>
      </div>

      <form id="login-form" class="form" onsubmit="return doLogin(event)">
        <label for="login-user">Username</label>
        <input id="login-user" autocomplete="username" required>
        <label for="login-pass">Password</label>
        <input id="login-pass" type="password" autocomplete="current-password" required>
        <button type="submit">Log in</button>
        <p id="login-msg" class="msg"></p>
      </form>

      <form id="register-form" class="form hidden" onsubmit="return doRegister(event)">
        <label for="reg-user">Username (3-20 letters, numbers, underscore)</label>
        <input id="reg-user" autocomplete="username" required>
        <label for="reg-pass">Password (at least 8 characters)</label>
        <input id="reg-pass" type="password" autocomplete="new-password" required>
        <button type="submit">Create account</button>
        <p id="reg-msg" class="msg"></p>
      </form>
    </section>

    <!-- ============ MEMBER AREA: protected ============ -->
    <section id="member-area" class="panel hidden">
      <div class="member-head">
        <h2>Welcome, <span id="me-name"></span> ๐Ÿ‘‹</h2>
        <button class="outline-btn" onclick="doLogout()">Log out</button>
      </div>
      <p class="muted">You can only see this because your browser sent a valid session cookie.</p>

      <h3>Your secret notes</h3>
      <form class="form row" onsubmit="return addNote(event)">
        <input id="note-input" placeholder="Write something private..." maxlength="200" required>
        <button type="submit">Add</button>
      </form>
      <ul id="note-list" class="notes"></ul>
    </section>

  </main>

  <script src="auth.js"></script>
</body>
</html>

๐Ÿ–ฅ auth-lab/auth.js โ€” the client

๐Ÿ“„ auth-lab/auth.js

The api() helper wraps fetch: it sends JSON for POST and returns { ok, status, data }. boot() runs on page load and calls /api/me โ€” if the server says 401 the guest forms show, otherwise the member area renders. That single call is the entire login state: nothing is stored in localStorage, the cookie is the only proof.

// auth.js โ€” the client block. Talks to /api/<action> which netlify.toml
// rewrites to our serverless function.

const $ = (id) => document.getElementById(id)

function showTab(name) {
  $('tab-login').classList.toggle('active', name === 'login')
  $('tab-register').classList.toggle('active', name === 'register')
  $('login-form').classList.toggle('hidden', name !== 'login')
  $('register-form').classList.toggle('hidden', name !== 'register')
}

async function api(action, method, body) {
  const res = await fetch(`/api/${action}`, {
    method: method || 'GET',
    headers: body ? { 'Content-Type': 'application/json' } : {},
    body: body ? JSON.stringify(body) : undefined,
  })
  const data = await res.json().catch(() => ({}))
  return { ok: res.ok, status: res.status, data }
}

function flash(el, text, isError) {
  el.textContent = text
  el.className = 'msg' + (isError ? ' error' : '')
}

async function doLogin(event) {
  event.preventDefault()
  const { ok, data } = await api('login', 'POST', {
    username: $('login-user').value,
    password: $('login-pass').value,
  })
  flash($('login-msg'), data.error || 'Logged in!', !ok)
  if (ok) enterMemberArea(data.user)
}

async function doRegister(event) {
  event.preventDefault()
  const { ok, data } = await api('register', 'POST', {
    username: $('reg-user').value,
    password: $('reg-pass').value,
  })
  flash($('reg-msg'), data.error || 'Account created!', !ok)
  if (ok) enterMemberArea(data.user)
}

async function doLogout() {
  await api('logout', 'POST')
  location.reload()
}

// Ask the server who we are. Called on page load.
async function boot() {
  const { ok, data } = await api('me')
  if (ok) {
    enterMemberArea(data.user)
  } else {
    $('guest-area').classList.remove('hidden')
  }
}

function enterMemberArea(user) {
  $('guest-area').classList.add('hidden')
  $('member-area').classList.remove('hidden')
  $('me-name').textContent = user.username
  loadNotes()
}

async function loadNotes() {
  const { ok, data } = await api('notes')
  const list = $('note-list')
  list.innerHTML = ''
  if (!ok || !data.notes) return
  for (const note of data.notes) {
    const li = document.createElement('li')
    li.innerHTML = `<span class="note-body"></span>
      <button class="outline-btn" title="Delete">โœ•</button>`
    li.querySelector('.note-body').textContent = note.body
    li.querySelector('button').onclick = () => deleteNote(note.id)
    list.appendChild(li)
  }
}

async function addNote(event) {
  event.preventDefault()
  const input = $('note-input')
  const { ok } = await api('notes', 'POST', { body: input.value })
  if (ok) {
    input.value = ''
    loadNotes()
  }
}

async function deleteNote(id) {
  const { ok } = await api(`notes/${id}`, 'DELETE')
  if (ok) loadNotes()
}

boot()

๐ŸŽจ auth-lab/styles.css โ€” the demo styling

๐Ÿ“„ auth-lab/styles.css

:root {
  --bg: #0f0f0f;
  --panel: #1a1a2e;
  --panel2: #16213e;
  --line: #2a2a4a;
  --text: #eee;
  --muted: #9aa0b4;
  --accent: #7c3aed;
  --accent-strong: #6d28d9;
  --danger: #e94560;
}

* { box-sizing: border-box; margin: 0; }

body {
  background: var(--bg);
  color: var(--text);
  font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
  min-height: 100vh;
}

.lab-top {
  border-bottom: 1px solid var(--line);
  background: var(--panel);
}
.lab-inner {
  max-width: 720px;
  margin: 0 auto;
  padding: 1rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.lab-logo { font-weight: 700; }
.lab-tag { color: var(--muted); font-size: 0.85rem; }

.lab-main {
  max-width: 720px;
  margin: 0 auto;
  padding: 2rem 1rem 4rem;
}

.panel {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 12px;
  padding: 1.5rem;
}

.panel-head { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; }

.tab-btn {
  flex: 1;
  padding: 0.65rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
  font-size: 1rem;
}
.tab-btn.active {
  background: var(--accent);
  border-color: var(--accent-strong);
  color: #fff;
}

.form { display: flex; flex-direction: column; gap: 0.5rem; }
.form label { font-size: 0.85rem; color: var(--muted); }
.form input {
  padding: 0.65rem;
  border-radius: 8px;
  border: 1px solid var(--line);
  background: var(--panel2);
  color: var(--text);
  font-size: 1rem;
}
.form input:focus { outline: 2px solid var(--accent); border-color: transparent; }

.form button, .outline-btn {
  padding: 0.65rem 1.1rem;
  border-radius: 8px;
  border: none;
  background: var(--accent);
  color: #fff;
  cursor: pointer;
  font-size: 1rem;
  font-weight: 600;
}
.form button:hover, .outline-btn:hover { background: var(--accent-strong); }
.form.row { flex-direction: row; align-items: center; }
.form.row input { flex: 1; }
.member-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 0.4rem;
}
.member-head h2 { font-size: 1.35rem; }
.outline-btn {
  background: transparent;
  border: 1px solid var(--line);
  color: var(--muted);
}
.outline-btn:hover { background: var(--panel2); color: var(--text); }
.muted { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.4rem; }

.notes { list-style: none; padding: 0; margin-top: 1.25rem; display: flex; flex-direction: column; gap: 0.5rem; }
.notes li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 0.75rem;
  background: var(--panel2);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 0.65rem 0.85rem;
}
.note-body { overflow-wrap: anywhere; }

.msg { min-height: 1.2em; font-size: 0.9rem; color: var(--muted); }
.msg.error { color: var(--danger); }

.hidden { display: none !important; }

8. The Blocks, Deep-Dive (The Point of This Project)

The reason this lab exists: every big system is these blocks repeating. Each card is one block with the idea, the real location in the code, and the lesson it teaches.

๐Ÿ— The ten blocks, one by one

  1. The Form (UI) โ€” HTML inputs a human fills in. auth-lab/index.html
  2. The Network call โ€” fetch() sends a POST with JSON to /api/register. auth-lab/auth.js
  3. The URL rewrite โ€” netlify.toml turns /api/register into a call to our function with action=register.
  4. The Router โ€” switch (action) picks which code runs. api.js
  5. Validation โ€” length and character rules before anything is stored.
  6. The Hash โ€” scryptSync + salt turns the password into a one-way string. lib/crypto.js
  7. The Database โ€” SQLite tables users, sessions, notes. lib/db.js
  8. The Session โ€” a random token stored server-side; signing makes it trustworthy. lib/crypto.js
  9. The Cookie โ€” the browser keeps it and sends it back automatically. api.js
  10. The Guard โ€” every protected action checks currentUser() first. api.js

๐Ÿ”ฌ Block spotlight โ€” what happens when you click "Create account"

1. Onsubmit fires, doRegister() sends the form
await api('register', 'POST', { username, password })

2. The function receives { "action": "register", "username": "...", "password": "..." }
const action = url.searchParams.get('action')

3. Rules are checked, then the password becomes a hash
const stored = hashPassword(password)   // scrypt:salt:hash

4. The row lands in SQLite โ€” plaintext never touches the disk
INSERT INTO users (username, pass_hash) VALUES (?, ?)

5. A session is created and the cookie header is built
Set-Cookie: sid=token.signature; HttpOnly; SameSite=Lax; Max-Age=604800
๐Ÿ”’ Analogy โ€” The Padlock That Can't Be Opened
A hash is a padlock with no keyhole: going forward is easy, going backward is impossible. You (the site) never need to open it โ€” when someone logs in, you give their password the same one-way treatment and compare the two results. The salt is like adding your own secret padlock prefix so two people who use "password123" still end up with different locks.

9. Security in Plain Words (What Could Go Wrong, and What We Did)

AttackWhat we did about it
Database leakedPasswords are scrypt hashes with per-user salt โ€” recovering the originals is intentionally too expensive
Stolen/edited cookieHMAC signature โ€” an edited token fails unsign() and is rejected (401)
Reused captured cookie on another deviceNot fully solved here โ€” real apps add HTTPS-only (our Secure flag in production) and device binding
Guessing another user's note idsDELETE ... WHERE id = ? AND user_id = ? โ€” ownership is part of the query
SQL injectionPrepared statements (? placeholders) โ€” input is data, never code
Spam registrations / password guessingNot solved here โ€” that is the rate limiting homework in section 12
XSS stealing the sessionHttpOnly keeps the cookie out of JavaScript's reach; the demo renders notes with textContent, never raw HTML

Honest scope note: this lab is for learning the motors, not for production traffic. The concepts are the production ones, but a real service adds rate limiting, email verification, password reset, and refreshing session keys.

10. Troubleshooting (When Something Goes Wrong)

SymptomMost likely causeFix
Register returns 500 and the log says Cannot find module 'better-sqlite3'npm install was never run, or ran in the wrong folderRun npm install in the project root next to package.json
Function api has returned an error: MissingBlobsEnvironmentErrorBlob credentials missing (unlinked local dev)The fallback file database takes over automatically โ€” or link the site with netlify link
Log in says "Wrong username or password" for a fresh accountThe database was reset (new instance / empty blob)Re-register; later deploys keep data because of Netlify Blobs
Always "Not logged in" even right after registeringCookie blocked or dev server restarted mid-sessionSecond click usually works once the cookie lands; check the Network tab for the Set-Cookie header
Unknown action / 404Typo in the URL or a stale redirectCheck netlify.toml and the exact endpoint names
Notes work but vanish after redeployThe blob store is empty on a brand-new deployNormal โ€” the first request creates it; entries stay after that

Rule of thumb: the browser's Developer Tools โ†’ Network tab shows every request and its status code, and the dev server terminal shows the function's console.log output. A 4xx is your input; a 5xx is the server โ€” fix 5xx first, this time it is always the database setup.

11. Glossary for Beginners

TermIn plain words
HashA one-way recipe: you cannot turn the result back into the password
SaltRandom characters added before hashing so identical passwords differ
SessionA record saying "user 7 is logged in" that the server keeps
CookieA small value the browser stores and sends with every request
HMACA fingerprint of a value computed with a secret key
Prepared statementSQL where values are passed separately โ€” injection-safe
Serverless functionCode that runs on a server for you, on demand, per request
HTTP statusThe three-digit code in every response: 200 OK, 400 bad input, 401 not logged in, 409 taken

12. Best Practices & Next Steps

โฑ Rate limiting
Count failed logins per IP in the sessions table and block after 5 โ€” the most important homework.
๐Ÿ” Rotate the secret
Change AUTH_LAB_SECRET on a schedule; old cookies simply stop validating.
๐Ÿ” Upgrade to real auth
The shapes you learned are the same as JWT + refresh tokens โ€” just thinner and stateless.
๐Ÿ—„ Swap databases
Replace lib/db.js with a Postgres client and the rest of the code does not change.
๐Ÿงช Attack it
Try deleting a note with a friend's id, sending malformed JSON, or reusing a cookie โ€” and read the status codes.
๐Ÿงญ Go deeper
Next: refresh tokens, magic links, or turning the notes into a full CRUD app with pagination.

You just built a full login system from scratch โ€” scrypt hashing, signed sessions, a real SQLite database, and per-user protected data. Ten blocks, one request cycle. Same shapes, bigger systems from here.

Created by jcmatira