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.
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.
| Feature | Built with |
|---|---|
| Create an account | Register form + scrypt password hashing |
| Log in / log out | Session tokens stored in SQLite + signed cookies |
| Stay signed in | A cookie the browser sends on every request |
| Protected pages | Server checks your session before showing anything |
| Per-user private notes | Each note row belongs to exactly one user |
| Real database | SQLite โ three tables, prepared statements |
Every request travels the same road. This is the whole system in one picture:
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" } }
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)
| File | What it does |
|---|---|
auth-lab/index.html | The page: login/register forms + the member area |
auth-lab/auth.js | Client logic: talks to the API, renders the member area |
auth-lab/styles.css | Visuals for the demo |
netlify/functions/api.js | The server: router + all auth actions |
netlify/functions/lib/crypto.js | Password hashing and cookie signatures |
netlify/functions/lib/db.js | SQLite setup + the persistence adapter |
netlify.toml | Enables functions and rewrites /api/* to them |
package.json | The two dependencies: better-sqlite3 and @netlify/blobs |
| Endpoint | Method | What it does |
|---|---|---|
/api/register | POST | Create a user (validates, hashes, logs you in) |
/api/login | POST | Verify password, mint a session cookie |
/api/logout | POST | Delete the session row, clear the cookie |
/api/me | GET | Who am I? (protected โ 401 without a session) |
/api/notes | GET / POST | List or add my notes (protected) |
/api/notes/<id> | DELETE | Delete 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.
You need Node.js (nodejs.org, the LTS button) and any code editor โ VS Code is the friendliest for beginners.
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.
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.
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.
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.
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.
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.
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.
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.
Here is every file in full. They are the exact files behind the live Auth Lab demo.
[build] publish = "." functions = "netlify/functions" [[redirects]] from = "/api/*" to = "/.netlify/functions/api?action=:splat" status = 200
{
"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"
}
}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 }
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 }
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.' }) } }) }
<!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 & 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>
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()
: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; }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.
fetch() sends a POST with JSON to /api/register. auth-lab/auth.jsnetlify.toml turns /api/register into a call to our function with action=register.switch (action) picks which code runs. api.jsscryptSync + salt turns the password into a one-way string. lib/crypto.jsusers, sessions, notes. lib/db.jscurrentUser() first. api.js1. 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
| Attack | What we did about it |
|---|---|
| Database leaked | Passwords are scrypt hashes with per-user salt โ recovering the originals is intentionally too expensive |
| Stolen/edited cookie | HMAC signature โ an edited token fails unsign() and is rejected (401) |
| Reused captured cookie on another device | Not fully solved here โ real apps add HTTPS-only (our Secure flag in production) and device binding |
| Guessing another user's note ids | DELETE ... WHERE id = ? AND user_id = ? โ ownership is part of the query |
| SQL injection | Prepared statements (? placeholders) โ input is data, never code |
| Spam registrations / password guessing | Not solved here โ that is the rate limiting homework in section 12 |
| XSS stealing the session | HttpOnly 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.
| Symptom | Most likely cause | Fix |
|---|---|---|
Register returns 500 and the log says Cannot find module 'better-sqlite3' | npm install was never run, or ran in the wrong folder | Run npm install in the project root next to package.json |
Function api has returned an error: MissingBlobsEnvironmentError | Blob 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 account | The database was reset (new instance / empty blob) | Re-register; later deploys keep data because of Netlify Blobs |
| Always "Not logged in" even right after registering | Cookie blocked or dev server restarted mid-session | Second click usually works once the cookie lands; check the Network tab for the Set-Cookie header |
Unknown action / 404 | Typo in the URL or a stale redirect | Check netlify.toml and the exact endpoint names |
| Notes work but vanish after redeploy | The blob store is empty on a brand-new deploy | Normal โ 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.
| Term | In plain words |
|---|---|
| Hash | A one-way recipe: you cannot turn the result back into the password |
| Salt | Random characters added before hashing so identical passwords differ |
| Session | A record saying "user 7 is logged in" that the server keeps |
| Cookie | A small value the browser stores and sends with every request |
| HMAC | A fingerprint of a value computed with a secret key |
| Prepared statement | SQL where values are passed separately โ injection-safe |
| Serverless function | Code that runs on a server for you, on demand, per request |
| HTTP status | The three-digit code in every response: 200 OK, 400 bad input, 401 not logged in, 409 taken |
AUTH_LAB_SECRET on a schedule; old cookies simply stop validating.
lib/db.js with a Postgres client and the rest of the code does not change.
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