Ship Data Apps with AI

👥 Users & money·Lesson 16 of 26·~30 min·Intermediate

👤 Accounts without building auth

One PocketBase login for every app, reached through each app's own domain, with rules that keep data private and a script that proves it.

You'll leave with

  • Optional sign-up and login in your app, with guest mode still working
  • Collection rules so people only see and change their own records
  • A script that proves a stranger can't read or change anyone's data

Sooner or later someone wants their progress on a second phone, or you want a free limit nobody can dodge. Both need accounts. I'm a data person, not a security engineer, so I don't write logins: one PocketBase holds the accounts for my apps, rules decide who sees what, and a script proves the rules before any login screen exists.

What's PocketBase?

A free, open-source program that is a whole small backend in one file: a database, sign-up and login, an admin dashboard, and room for your own server code (called hooks). You run it in a container next to your apps.

⚡ The 60-second version

  1. One PocketBase container serves every app. Its dashboard lives only at accounts.yourdomain.com.
  2. Each app's Caddy block sends /api/* to it, and the dashboard path /_/ stays 404 there.
  3. Rules lock the data, and a script proves it as owner, stranger and logged-out visitor.
  4. Then the optional login screen: guests keep everything, and sign-up imports their progress once.

Never write your own login

A login box takes an afternoon. A safe one needs password hashing, expiring sessions, resets and throttling, and a mistake in any of them leaks other people's passwords. So one PocketBase is the user list for Acento, First Bites, Baby Signs, Grease, Traqqit Max and this course, while each app's data lives in its own collections (PocketBase's tables). One account works everywhere, with a separate session per domain: your account for this course's checkmarks works on Grease too, but you sign in there once.

What are a session and a token?

When you log in, the server hands your browser a token, a long random string meaning "this is Sam, for now". The browser sends it with every request (each message asking the server for something); that logged-in state is your session. Each domain has its own browser storage, so each app keeps its own token.

I started with the app that had the least to lose. That was Acento, and moving it was a proxy-route change. Every later app reused that flow.

🦥 Skipped: paid login services, "Sign in with Google", magic links and two-factor codes. Email and password is the smallest thing that works; add more when people ask.

If an app truly must roll its own

One of my side projects needed accounts kept apart from the fleet, so a small module replaced the shared system there completely, alerts included: two login systems in one app are worse than either. Its checklist is the minimum:

  • A slow hash with a random salt per user (Node's crypto.scryptSync), never the password itself.
  • Constant-time comparison (crypto.timingSafeEqual), hashing even for unknown emails, so timing reveals nothing.
  • Session tokens stored only as hashes, all ended when the password changes.
  • Login throttling: at most 20 attempts per IP address per 15 minutes.
  • The accounts file outside any web-served folder, with curl proving its path returns 404.
What are a hash and a salt?

A hash is a one-way scramble of a password: you can check a guess against it, but never turn it back. A salt is a random value mixed in per user, so equal passwords get different hashes. Password hashes are slow on purpose, so guessing millions costs too much. A constant-time comparison takes equally long however wrong a guess is.

1

Set it up once

Once per server, not per app, on the front door from Deploy behind one front door (the web network, the caddy container, your infra repo), with two terminal tabs: laptop and server. In a new Claude Code session, type /add-dir ../infra first, as in Deploy. Replace YOUR_VPS_IP, yourdomain.com and myapp with yours throughout.

A name, then a container

First the name: an A record, the DNS line pointing a name at your server, made as you did for myapp. Ask Claude: "Add an A record for accounts.yourdomain.com pointing at my VPS without overwriting anything, then wait until 1.1.1.1 returns it." Then check it yourself: nslookup accounts.yourdomain.com 1.1.1.1 must answer YOUR_VPS_IP before Caddy can get a certificate for the name. Then the container:

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouRun PocketBase, pinned to the current release, in a container named pb on the web network, with data in /root/pb/pb_data, hooks in /root/pb/pb_hooks and no published ports. Keep its Dockerfile in infra/pb. Add accounts.yourdomain.com → pb:8090 to my Caddyfile, then validate and reload. Don't create the admin login or read the container's logs; I'll handle both.
Claudepb is Up with no ports, built from the version pinned in infra/pb/Dockerfile. The accounts block is committed, validated and reloaded, and curl https://accounts.yourdomain.com/api/health answers "API is healthy."↳ Write · infra/pb/Dockerfile↳ Edit · infra/Caddyfile↳ Bash · git commit · scp · ssh root@YOUR_VPS_IP · docker build · docker run · docker ps · caddy validate · caddy reload · curl

What's happening underneath: PocketBase has no official Docker image, so a short Dockerfile (Docker's build recipe) makes one from the official release:

FROM alpine:3
ARG PB_VERSION=YOUR_PB_VERSION
RUN apk add --no-cache unzip ca-certificates
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
RUN unzip /tmp/pb.zip -d /pb && rm /tmp/pb.zip
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"]

Replace YOUR_PB_VERSION with the newest number on PocketBase's releases page, without the v (if uname -m on the server prints aarch64, change amd64 to arm64). PocketBase hasn't reached 1.0 and doesn't promise compatibility between releases: pin it, and read the changelog before changing it.

ssh root@YOUR_VPS_IP 'mkdir -p /root/pb/image /root/pb/pb_data /root/pb/pb_hooks'
scp pb/Dockerfile root@YOUR_VPS_IP:/root/pb/image/
docker build -t pb /root/pb/image
docker run -d --name pb --restart unless-stopped --network web \
  -v /root/pb/pb_data:/pb/pb_data -v /root/pb/pb_hooks:/pb/pb_hooks pb

No -p (no published port), so only containers on web reach pb:8090 (the Dockerfile's 0.0.0.0 means "answer on every address the container has", which lets Caddy in). pb_data now holds every account: the first folder Keep the server boring backs up. The front door gets one block, shipped like every block:

accounts.yourdomain.com {
	reverse_proxy pb:8090
}

This is the only address where all of PocketBase, dashboard included, faces the internet. Check it (in Windows PowerShell, type curl.exe):

curl https://accounts.yourdomain.com/api/health
{"message":"API is healthy.","code":200,"data":{}}

A 502 means Caddy can't reach pb; docker logs pb on the server says why.

The superuser: you, not Claude

What's a superuser?

PocketBase's admin account. It skips every rule and can read or change anything, so it's the one login that must never leak.

On its first start, PocketBase prints a one-time link for creating the superuser. For 30 minutes that link is a key to your whole database, so read it in your own server tab, not through Claude:

docker logs pb 2>&1 | grep pbinstall
http://0.0.0.0:8090/_/#/pbinstall/eyJhbGciOiJIUzI1NiIs…

In a text editor, replace the part before /_/ (something like http://0.0.0.0:8090) with https://accounts.yourdomain.com. Open the result in your browser and enter your email and a long password from your password manager. It worked when the dashboard opens with users under Collections. No line, or an expired link? docker restart pb prints a fresh one; PocketBase does that at every start until a superuser exists.

Scripts need that login later, so store it the way Deploy stored secrets: open the file in nano, type the two lines below, save (Ctrl+O, Enter) and leave (Ctrl+X).

mkdir -p /root/secrets && chmod 700 /root/secrets
nano /root/secrets/pb-admin.env
chmod 600 /root/secrets/pb-admin.env
PB_ADMIN_EMAIL=you@yourdomain.com
PB_ADMIN_PASSWORD=YOUR_SUPERUSER_PASSWORD

Replace both values, with no quotes. An env file like this is plain text, one NAME=value per line; Docker's --env-file hands it to a program as settings when it starts, so Claude never has to see the password.

🛑 Never paste the superuser password or the setup link into a chat, a repo or a page. If the password leaks, change it in the dashboard and in the env file the same day.

Accounts are optional

The whole app works without signing up. An account adds sync, sharing or a higher limit: sync between phones and family sharing in First Bites, saved progress and a leaderboard in Acento, checkmarks in this course. It adds a feature and never unlocks the basics, so the first visit stays instant and you hold less personal data. First Bites' first version shipped in a day with no accounts at all.

Guest first: progress lives in localStorage (the browser's per-site storage from Build static-first) until sign-up, when the app uploads it once so nothing resets. Acento's import never awards leaderboard points; only the server's own grading does.

After login, save each item (a favorite, a setting, one logged event) as its own record on the server and read them back from there. Two devices then never overwrite each other, so there's no merge code. If you also keep a browser copy that syncs between phone and laptop, read Sync and sharing that never lose work first: a naive upload silently overwrites one device with the other.

You can launch sign-up before your server sends email. Acento did, with "no email verification, no password reset" on the NOT DONE list in NOTES.md (from The laziest thing that works) instead of hidden. The next lesson wires email; do it before you take money, because a paying customer who forgets a password has no way back in.

2

Reach it through the app's own domain

Your page could call https://accounts.yourdomain.com directly, but that's a cross-site call, and it drags in CORS. Instead, each app's block sends /api/* to PocketBase, so the browser sees one site. (If last lesson's Node server already uses /api, read "When /api is already taken" below first.)

What are same-origin and CORS?

Same-origin means the page calls its own site, like https://myapp.yourdomain.com, which browsers allow freely. Calling another site triggers CORS: browser rules that make that server list who may call it, plus extra "preflight" requests and cookie limits.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouIn infra/Caddyfile, make the myapp block send /api/* to pb:8090 and everything else to myapp:80, keeping the private-paths rule working and never forwarding /_/. Ship it, then prove that /api/health is 200 and /_/ and /.env are 404.
ClaudeShipped, with the private-paths rule moved into its own handle. First lines: /api/health HTTP/2 200, /_/ HTTP/2 404, /.env HTTP/2 404.↳ Edit · infra/Caddyfile↳ Bash · scp · caddy validate · caddy reload · curl -sI

What's happening underneath: this lesson's whole front-door config, the finished myapp block plus the accounts block:

myapp.yourdomain.com {
	encode zstd gzip
	header Cache-Control "no-cache"
	@private path /.git/* /.env* *.env *.bak *.old *.zip *.tgz *.sql
	handle @private {
		respond 404
	}
	handle /api/* {
		reverse_proxy pb:8090
	}
	handle {
		reverse_proxy myapp:80
	}
}

accounts.yourdomain.com {
	reverse_proxy pb:8090
}

Each request enters exactly one handle: /api/... goes to PocketBase, private paths get a 404, the rest goes to your app's files. The dashboard path /_/ isn't under /api, so your file server answers 404; forwarding all of PocketBase would put the admin login on every app domain.

⚠️ A trap worth knowing: keep Deploy's bare respond @private 404 line, add handle blocks, and Caddy runs the handles first. The catch-all one takes every request, so /.env is served again, with no error anywhere.

Check it yourself (PowerShell: curl.exe), reading the first line of each answer:

curl -sI https://myapp.yourdomain.com/api/health
curl -sI https://myapp.yourdomain.com/_/
curl -sI https://myapp.yourdomain.com/.env
HTTP/2 200 HTTP/2 404 HTTP/2 404

Run the /_/ line on every app domain with an /api route, as Baby Signs was checked. First Bites got its login from this one Caddy edit. If a new app can't log in, check this route first; add it before the login screen ships.

When /api is already taken

If /api already goes to your own server (the Node one from Add a backend only when forced), keep that handle and forward only the accounts API under a prefix:

handle /accounts/api/* {
	uri strip_prefix /accounts
	reverse_proxy pb:8090
}

The page calls /accounts/api/... and PocketBase receives /api/.... Don't use handle_path /accounts/*: it forwards everything under the prefix, so /accounts/_/ would serve the dashboard. Check that it answers 404.

3

Rules are your security

Your page is not the lock: anyone can open the browser console and send any request your page could, with any values. The lock is the rules PocketBase checks on every request.

What are a collection, a record and a rule?

A collection is a table (users is one) and a record is one row in it. Each collection has five rules, one per action: list and view (GET requests), create (POST), update (PATCH, which changes part of a record) and delete (DELETE). A rule is a condition like id = @request.auth.id ("this record is the logged-in person's"), checked first.

Actionusersmyapp_items
List, viewid = @request.auth.id: your own record only@request.auth.id != "" && user = @request.auth.id
Createempty: anyone can sign upthe same owner rule
Updatelocked: nobody, through the APIthe same owner rule
Deleteid = @request.auth.id: yourself onlythe same owner rule

PocketBase's defaults for users match this table except Update, which lets people edit their own record. Lock it, or the day you add a paid field, anyone can make themselves Pro from the console with one request. Password resets and email changes still work; PocketBase handles them separately. Each change you do allow (a nickname, say) gets a small server route that checks it; the rest goes on NOT DONE, like the "no password-change screen" on Acento's launch list.

⚠️ An empty rule means everyone, logged out included; a locked rule (null) means superusers only. Swapping them opens a door or locks out your users, so read rules back: the script below prints '' for empty and null for locked.

A public create rule accepts any field, so a crafted sign-up could arrive already marked as paid. A hook fixes that:

What's a hook?

Your own JavaScript that PocketBase runs on the server at a given moment ("a user is about to be created"), or as a new route (an address it answers, like /api/myapp/use). Hooks live in pb_hooks and skip the rules.

// Signup guard: whatever the browser sent, a new account starts with nothing paid.
onRecordCreateRequest((e) => {
  e.record.set("entitlements", "[]");
  e.next();
}, "users");

entitlements, a JSON list (JSON is the plain-text data format APIs speak) of what someone has bought, stays empty until you sell something; Get paid without leaking money lets only verified messages from Stripe, the payment service, write it.

Saved items get their own collection: the owner rule on all five actions, no server code. This course's checkmarks work that way, a user plus a list of completed lessons, added to the live shared server with no hooks, no restart and no downtime.

What's a relation (and cascade delete)?

A relation field points at a record in another collection: user points at one person in users. Cascade delete removes someone's items when the person is deleted.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouWrite infra/pb/setup-pb.mjs, safe to run twice: on users, keep every field, add a json field entitlements and lock updates; create myapp_items with owner-only rules for list, view, create, update and delete. Run it on the server with my pb-admin.env, upload main.pb.js, and show me the rules.
ClaudeThe second run printed users: nothing to add. Users' update rule is null; all five myapp_items rules are the owner rule. docker logs --tail 20 pb shows no errors since the upload.↳ Write · setup-pb.mjs · main.pb.js↳ Bash · node --check · scp · ssh root@YOUR_VPS_IP · docker run --rm · docker logs

What's happening underneath:

// setup-pb.mjs: safe to run twice. It adds what's missing and never removes anything.
const PB = process.env.PB_URL || 'http://pb:8090';
const OWNER = '@request.auth.id != "" && user = @request.auth.id';
const USER_FIELDS = [
  { name: 'entitlements', type: 'json' },   // what someone has paid for: empty until you sell
];

async function api(method, path, body, token) {
  const res = await fetch(PB + path, {
    method,
    headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: token } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${text}`);
  return text ? JSON.parse(text) : null;
}

const { token } = await api('POST', '/api/collections/_superusers/auth-with-password',
  { identity: process.env.PB_ADMIN_EMAIL, password: process.env.PB_ADMIN_PASSWORD });

// users: send back EVERY existing field plus the missing ones, and lock updates
const users = await api('GET', '/api/collections/users', null, token);
const add = USER_FIELDS.filter((f) => !users.fields.some((old) => old.name === f.name));
await api('PATCH', '/api/collections/users', { fields: [...users.fields, ...add], updateRule: null }, token);
console.log(add.length ? 'added to users: ' + add.map((f) => f.name).join(', ') : 'users: nothing to add');

// myapp_items: one record per saved item, owner-only for all five actions
const all = await api('GET', '/api/collections?perPage=500&fields=name', null, token);
if (!all.items.some((c) => c.name === 'myapp_items')) {
  await api('POST', '/api/collections', {
    name: 'myapp_items',
    type: 'base',
    fields: [
      { name: 'user', type: 'relation', collectionId: users.id, maxSelect: 1, required: true, cascadeDelete: true },
      { name: 'data', type: 'json' },
    ],
    listRule: OWNER, viewRule: OWNER, createRule: OWNER, updateRule: OWNER, deleteRule: OWNER,
  }, token);
  console.log('created myapp_items');
}

for (const name of ['users', 'myapp_items']) {
  const c = await api('GET', '/api/collections/' + name, null, token);
  console.log(name, { list: c.listRule, view: c.viewRule, create: c.createRule, update: c.updateRule, delete: c.deleteRule });
}

The line to understand is the PATCH: it sends back every existing field plus the new ones, because a fields list without the old fields deletes them, data included. The script runs on the server, reaching PocketBase by name with the login from the env file, never printed. Database first, code second:

ssh root@YOUR_VPS_IP 'mkdir -p /root/pb/scripts'
scp pb/setup-pb.mjs root@YOUR_VPS_IP:/root/pb/scripts/
docker run --rm --network web --env-file /root/secrets/pb-admin.env \
  -v /root/pb/scripts:/scripts:ro node:24-alpine node /scripts/setup-pb.mjs
added to users: entitlements created myapp_items users { … create: '', update: null, … } myapp_items { list: '@request.auth.id != "" && user = @request.auth.id', …
scp pb/main.pb.js root@YOUR_VPS_IP:/root/pb/pb_hooks/main.pb.js

PocketBase loads the new file within seconds; if it can't, docker logs --tail 20 pb shows the error.

Things people send you

Submissions (a suggested word, a reported mistake) go into a collection with all five rules locked, reached only through two routes you write: submit (logged in, size-checked, refused at 25 pending per person) and a superuser-only review. Acento works this way, and a human approves every suggestion before it reaches the content files.

Let people delete their account

The default delete rule on users already lets people delete only themselves: keep it, and add a Delete my account button; cascade delete takes their items along. With shared data, first delete what only they own and scrub their email from shared records, stopping at any failed request. Then confirm on the server that the user, their data and their memberships are gone, as First Bites did: Google Play requires deletion both in the app and on the web (Ship to Google Play builds the page).

Limits and leaderboards the server decides

A limit checked only in the browser is decoration. Acento's grading route counts today's reviews on the server and, at 10, refuses with HTTP 402 and daily_limit. Every success returns what's left, so the page can say "7 left today".

What do 401, 402, 403 and 404 mean?

Status codes, the number at the start of every answer. 401: log in first. 402: Payment Required, used for "free limit reached". 403: logged in, but not allowed. 404: not found, which PocketBase also answers when a record exists but isn't yours.

// Signup guard: whatever the browser sent, a new account starts with nothing paid.
onRecordCreateRequest((e) => {
  e.record.set("entitlements", "[]");
  e.next();
}, "users");

// The free limit, counted on the server. Everything a handler needs lives inside it.
routerAdd("POST", "/api/myapp/use", (e) => {
  const LIMIT = 10;
  const user = e.auth;                                  // the logged-in person's record
  const today = new Date().toISOString().slice(0, 10);  // today's date in UTC
  let count = user.getString("myapp_day") === today ? user.getInt("myapp_count") : 0;
  const isPro = false;                                  // replaced when you sell Pro
  if (!isPro && count >= LIMIT) return e.json(402, { error: "daily_limit", remaining: 0 });
  count++;
  user.set("myapp_day", today);
  user.set("myapp_count", count);
  e.app.save(user);
  return e.json(200, { remaining: LIMIT - count });
}, $apis.requireAuth("users"));

// The public board: only people who picked a nickname, and only nickname + points.
routerAdd("GET", "/api/myapp/leaderboard", (e) => {
  const rows = e.app.findRecordsByFilter("users", "nickname != '' && myapp_points > 0", "-myapp_points", 20, 0);
  const board = [];
  for (const r of rows) board.push({ nickname: r.getString("nickname"), points: r.getInt("myapp_points") });
  return e.json(200, board);
});

These routes read four new fields, so the database changes first again: grow the list in setup-pb.mjs to this, rerun it, and only then upload the hook file.

const USER_FIELDS = [
  { name: 'entitlements', type: 'json' },   // what someone has paid for: empty until you sell
  { name: 'myapp_day', type: 'text' },      // the free limit's day (UTC)
  { name: 'myapp_count', type: 'number' },  // uses on that day
  { name: 'nickname', type: 'text' },       // public, opt-in, shared by all your apps
  { name: 'myapp_points', type: 'number' }, // leaderboard points, awarded by the server only
];

The page shows its limit message only on a 402; any other failure says "Couldn't save. Check your connection." Guests get a counter in localStorage instead, which anyone can reset: a nudge to sign up, not a wall.

toISOString() gives the date in UTC, the world's reference clock, so "today" flips at midnight UTC: 5 p.m. in Phoenix. Say "resets at 00:00 UTC", or compute the day in each person's own time zone.

Leaderboards too: if the browser sends its own score, someone will send a million. Acento awards points only inside the server's grading route; drills that run purely in the browser award nothing. Listing is opt-in through a public nickname, set by a small route that accepts 3 to 20 lowercase letters, digits or underscores and answers 409 ("conflict") if it's taken. The public route returns only nickname and points, and requiring a point leaves out people who only use your other apps.

4

Prove the rules before building screens

A rule can look right and still leak, usually because only the happy path was tried. So before any screen, a script plays owner, stranger and logged-out visitor against the real rules. First Bites proved its rules this way before a single sharing screen existed (members read and write, strangers get 404, nobody forges a membership, non-owners can't delete), and passed a 55-check data-removal test against production before launch.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouWrite infra/pb/prove-rules.mjs: sign up two test users, prove user B can't list, read or change user A's records, a logged-out visitor sees nothing and nobody can write a paid field, then delete both. Run it against https://myapp.yourdomain.com.
ClaudeIt ends with ALL PASSED: 13 rule checks through your real domain, then two more as both test users deleted themselves, which proves self-delete too.↳ Write · infra/pb/prove-rules.mjs↳ Bash · node prove-rules.mjs https://myapp.yourdomain.com

What's happening underneath: plain Node with no packages, talking to your app's own domain, so it tests the route as well:

// prove-rules.mjs: tries every allowed and forbidden action against the real rules.
// Run: node prove-rules.mjs https://myapp.yourdomain.com
if (!process.argv[2]) throw new Error('Give me your app address, e.g. https://myapp.yourdomain.com');
const API = process.argv[2].replace(/\/$/, '') + '/api';
const RUN = Date.now();
const made = [];
let failed = 0;

function check(ok, what) {
  console.log((ok ? 'PASS  ' : 'FAIL  ') + what);
  if (!ok) failed++;
}

async function call(method, path, body, token) {
  const res = await fetch(API + path, {
    method,
    headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: token } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  let json = null;
  try { json = JSON.parse(text); } catch {}
  return { status: res.status, json };
}

async function newUser(tag, extra = {}) {
  const email = `test+${tag}${RUN}@example.com`;
  const password = 'pw-' + crypto.randomUUID();
  const signup = await call('POST', '/collections/users/records', { email, password, passwordConfirm: password, ...extra });
  if (signup.status !== 200) throw new Error(`sign-up failed (${signup.status}): is /api routed to PocketBase?`);
  const login = await call('POST', '/collections/users/auth-with-password', { identity: email, password });
  const user = { email, id: signup.json.id, token: login.json.token };
  made.push(user);
  return user;
}

try {
  const A = await newUser('a', { entitlements: ['myapp_pro'] });  // tries to sign up already paid
  const B = await newUser('b');

  const me = await call('GET', `/collections/users/records/${A.id}`, null, A.token);
  check(JSON.stringify(me.json.entitlements) === '[]', 'a sign-up cannot smuggle in a paid field');
  const pro = await call('PATCH', `/collections/users/records/${A.id}`, { entitlements: ['myapp_pro'] }, A.token);
  check(pro.status >= 400, `nobody can edit their own account record (got ${pro.status})`);

  const item = await call('POST', '/collections/myapp_items/records', { user: A.id, data: { note: 'private' } }, A.token);
  check(item.status === 200, 'the owner can save an item');
  const it = `/collections/myapp_items/records/${item.json.id}`;

  const list = await call('GET', '/collections/myapp_items/records', null, B.token);
  check(list.status === 200 && list.json.totalItems === 0, 'a stranger lists nothing');
  check((await call('GET', it, null, B.token)).status === 404, 'a stranger reading it gets 404');
  check((await call('PATCH', it, { data: { note: 'changed' } }, B.token)).status === 404, 'a stranger changing it gets 404');
  check((await call('DELETE', it, null, B.token)).status === 404, 'a stranger deleting it gets 404');
  const forged = await call('POST', '/collections/myapp_items/records', { user: A.id, data: {} }, B.token);
  check(forged.status >= 400, `a stranger can't save an item in A's name (got ${forged.status})`);
  check((await call('GET', `/collections/users/records/${A.id}`, null, B.token)).status === 404, "a stranger can't read A's account");

  check((await call('GET', it)).status === 404, 'a logged-out visitor reading it gets 404');
  const anon = await call('POST', '/collections/myapp_items/records', { user: A.id, data: {} });
  check(anon.status >= 400, `a logged-out visitor can't save anything (got ${anon.status})`);
  const users = await call('GET', '/collections/users/records');
  check(users.status === 200 && users.json.totalItems === 0, 'a logged-out visitor lists no accounts');

  const after = await call('GET', it, null, A.token);
  check(after.json?.data?.note === 'private', "A's item is still there, unchanged");
} catch (err) {
  check(false, err.message);
} finally {
  for (const u of made) {
    const gone = await call('DELETE', `/collections/users/records/${u.id}`, null, u.token);
    check(gone.status === 204, `deleted ${u.email}`);
  }
}
console.log(failed ? `${failed} FAILED` : 'ALL PASSED');
process.exitCode = failed ? 1 : 0;

Run it from your laptop, in PowerShell or Git Bash. Using the /accounts prefix? Add it to the address.

node prove-rules.mjs https://myapp.yourdomain.com
PASS a sign-up cannot smuggle in a paid field PASS nobody can edit their own account record (got 403) PASS the owner can save an item … ALL PASSED

The test addresses use example.com, reserved for examples, so no email can go out. sign-up failed (404) means step 2's route is missing; any other FAIL names the rule to fix.

Test on production, then clean up in the same session. The script deletes its users; confirm it in the dashboard (open users, search for test+: nothing), then re-check anything public, like a leaderboard. After every live run, Acento's test user was deleted and its leaderboard checked back to normal.

Living with a shared users table

Every app now shares one users collection, so a mistake there reaches all of them. Four habits keep it boring.

What are a schema and a migration?

The schema is the list of collections and their fields, types and rules. A migration changes it, say by adding a field. On a database with real users, a careless migration is a delete.

Only add, never rebuild. Acento's original setup script deleted and re-added its own fields, harmless on day one. Once the users collection served the fleet, re-running it would have erased everyone's saved progress. So live changes are additive, like setup-pb.mjs, and any old script that drops fields gets DO NOT RUN ON PRODUCTION at the top.

Prefix what belongs to one app. On a shared table, points means points in which app? So it's myapp_points, while shared fields like the nickname stay bare. Acento's leaderboard field got its prefix by a rename in place: a collection PATCH that changed only the name and kept the field's id. The scores survived, and the server kept answering {points: N}, so the page needed no edits. Delete-then-add leaves an empty field instead.

The next app gets its own collection. Counters on the shared user record are a fine shortcut while one app has server data, and Acento's live there. Write down now that the next app's server data goes in its own collection, linked to the user.

Treat hooks with care. Three surprises:

  • Each handler (the function that answers a route) runs in its own sealed-off sandbox, so a helper defined at the top of the file doesn't exist inside it ("helper is not defined"). Define what a handler needs inside it, as LIMIT is above.
  • Store structured data as JSON text. Acento's per-card progress came back mangled from a json field written by a hook, until it went into a text field with JSON.stringify and came out with JSON.parse.
  • Uploading is the deploy: PocketBase reloads a new main.pb.js within seconds without a container restart, though every app blips, so ship at a quiet hour. Acento's leaderboard went live this way with the container's start time unchanged; Claude Code's safety check had refused to restart the shared server anyway, rightly.

The routine for every hook change: health check, syntax check, backup, new code added only at the end of the file, upload, health check again. In Git Bash or a Mac terminal (&& means "only if the previous step worked"):

curl -sI https://accounts.yourdomain.com/api/health | head -1
node --check main.pb.js \
  && ssh root@YOUR_VPS_IP 'mkdir -p /root/backups && cp /root/pb/pb_hooks/main.pb.js /root/backups/main.pb.js.$(date +%F-%H%M)' \
  && scp main.pb.js root@YOUR_VPS_IP:/root/pb/pb_hooks/main.pb.js
sleep 10
curl -sI https://accounts.yourdomain.com/api/health | head -1
curl -s -X POST https://accounts.yourdomain.com/api/myapp/use

Both health lines should read HTTP/2 200, and the last command should answer 401 ("requires valid record authorization token"): the route is up and still locked. If the second health line isn't 200, or the route answers 404, roll back first and read docker logs --tail 50 pb second. To roll back, copy the backup over the live file, replacing the name below with the one ls /root/backups shows on the server:

ssh root@YOUR_VPS_IP 'cp /root/backups/main.pb.js.2026-10-03-1420 /root/pb/pb_hooks/main.pb.js'

An empty list is not an error. My private admin page once showed "0 accounts", as if every user had been deleted. The database still had them all: the page's token had expired, and under a rule like id = @request.auth.id, PocketBase answers a bad token with 200 and an empty list. Check the session when a page loads, and before you believe data vanished, count rows read-only:

apt update && apt install -y sqlite3
sqlite3 -readonly /root/pb/pb_data/data.db 'select count(*) from users'

A false "everything's gone" is how people restore an old backup over good data.

5

Do it with Claude: the prompts

With the rules proven, build the screen. Guest data from Build static-first sits under myapp.v1; the token gets its own key, so it never travels inside your data.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouAdd optional login to index.html against /api (PocketBase). Guest mode keeps working; on first login, import local progress once. Keep the token under myapp.token, save each item as its own myapp_items record, and add a Delete my account button.
ClaudeAdded an Account button and a sign-up / log-in sheet; logged out, the app runs as before. Every call is a relative /api/... path: index.html contains no address of the accounts server.↳ Edit · index.html↳ Grep · accounts.yourdomain.com

What's happening underneath: six requests to your own domain, with the token sent after login in the Authorization header (a labeled line that travels with each request). Search Claude's code for these paths:

WhenRequestSends
Sign upPOST /api/collections/users/recordsemail, password, passwordConfirm
Log inPOST /api/collections/users/auth-with-passwordidentity (the email), password; gets the token
Every launchPOST /api/collections/users/auth-refreshthe token; a 401 signs out but keeps the device's data
Once, after sign-upPOST /api/collections/myapp_items/recordsone per guest item: user (your id), data
Load your itemsGET /api/collections/myapp_items/recordsthe token; the rules return only yours
Delete my accountDELETE /api/collections/users/records/YOUR_IDthe token

Check it yourself after deploying, in Chrome or Edge on a computer (Safari hides these tools until you switch them on; phones can't). Open your app in a private window (Ctrl+Shift+N, Mac: Cmd+Shift+N), press F12 (Mac: Cmd+Option+I) to open DevTools, the browser's developer tools, and click Network: it only records while open. Save a few things as a guest, then sign up with your own address. The auth-with-password row shows 200 from your own domain, and under Application → Local storage → your site's address, myapp.token is there. Your guest items are still on screen and in the dashboard's myapp_items. Log out: the app still works. Press Delete my account: the user and their items vanish from the dashboard.

The three prompts, in the order this lesson used them:

StepPrompt
RulesCreate myapp_items with owner-only rules for list, view, create, update and delete, and show me the rules.
ProofWrite a script that signs up two test users and proves user B can't list, read or change user A's records, then deletes both.
ScreenAdd optional login to index.html against /api (PocketBase). Guest mode keeps working; on first login, import local progress once.

Underneath all three: PocketBase checks a collection's rules on every request, so the rules, not your page, are the lock. The screen only decides what people see.

Your turn

  • curl https://accounts.yourdomain.com/api/health says API is healthy., and you log in to https://accounts.yourdomain.com/_/ as the superuser you created yourself.
  • On myapp.yourdomain.com, /api/health answers HTTP/2 200 while /_/ and /.env answer HTTP/2 404.
  • setup-pb.mjs prints update: null for users and the owner rule on all five myapp_items actions, and a second run prints users: nothing to add.
  • node prove-rules.mjs https://myapp.yourdomain.com ends with ALL PASSED, and the dashboard finds no test+ users afterwards.
  • In a private window, your guest items survive sign-up and appear as myapp_items records; logged out, the app still works; your Delete my account button removes the account.
  • NOTES.md lists "no email verification, no password reset" under NOT DONE.

That last item is next. Email that arrives turns on SMTP (how a server hands mail to your mail host) in this same PocketBase, so verification and reset emails reach inboxes.