Best practices
How to build an app that passes review the first time and that a business can trust with its customers: authorization first, then input, data, secrets, speed, and the checklist we review against.
Authorization first
Most leaks are one missing check. Decide who may call each route before you write it, and check it on the server, every time.
- Deny by default. A route with no guard is public. Every route that isn't meant to be public starts with
withViewerandrequireAuth. - Keep sameOrigin and csrf on the file that mounts your routes. Browser code sends writes with
siteFetch, which adds the CSRF token. - The owner is in the query. Ids are easy to guess. Every read, update and delete by id carries the owner (
user_id = me) in the same query, and someone else's id answers 404, not 403, so it doesn't confirm the row exists. - Staff screens need a permission. Your app can't add permissions of its own: the site's permission list is not one of your files. Gate on one the site has, such as
site:user:readfor screens that show people's details. Hiding a button is not access control. - Never trust ownership from the client.
user_id,role,status, prices and counters come from the server, never from the request body. - Use the site's sign-in. No login, password check, token or session of your own.
import type {APIRoute} from 'astro';
import {Hono} from 'hono';
import {csrf, sameOrigin} from '../../../../platform/guards';
import {routes} from '../../../../server/apps/my-app/routes';
export const prerender = false;
// Keep both guards: they stop other websites from sending requests as your visitors.
const app = new Hono()
.basePath('/site-api/apps/my-app')
.use(sameOrigin)
.use(csrf)
.route('/', routes);
export const ALL: APIRoute = ({request}) => app.fetch(request);import {Hono} from 'hono';
import {problem, requireAuth, requirePermission, withDb, withViewer} from '../../../platform/guards';
export const routes = new Hono()
// Public: anyone may call it. Say so in a comment, so a reviewer knows it is meant.
.get('/services', withDb, async c => { … })
// Signed in, own rows only: the owner is in every query.
.get('/bookings', withViewer, requireAuth, withDb, async c => { … })
// Staff only: a permission the site already has. Hiding the button is not enough.
.get('/signups', withViewer, requireAuth, requirePermission('site:user:read'), withDb, async c => { … });Prove it with four tests
For every route that reads or writes by id, write an integration test against a real Postgres (TEST_DATABASE_URL=… npm test):
| Who calls | Expected answer |
|---|---|
| Signed out | 401 |
| Signed in, without the permission | 403 |
| Signed in, another person's id | 404 |
| The owner, or staff with the permission | 200 |
Validate every input
Check the type, the trimmed length, the allowed values and the format of every field, and reject what doesn't fit instead of repairing it. Integers go through Number.isSafeInteger. Lists in a body have a maximum length (100 or fewer). Request bodies are capped at 64 KB.
import {validator} from 'hono/validator';
const bookingBody = validator('json', (value, c) => {
const v = (value ?? {}) as Record<string, unknown>;
const serviceId = Number(v.serviceId);
const startsAt = new Date(String(v.startsAt ?? ''));
const note = typeof v.note === 'string' ? v.note.trim() : '';
if (!Number.isSafeInteger(serviceId) || serviceId < 1) {
return c.json(problem('invalid_service', 'Pick a service.'), 422);
}
if (Number.isNaN(startsAt.getTime())) return c.json(problem('invalid_time', 'Pick a time.'), 422);
if (note.length > 500) {
return c.json(problem('invalid_note', 'A note can be up to 500 characters.'), 422);
}
// Only the known fields go on: never userId, status or a price from the body.
return {serviceId, startsAt, note: note || null};
});- Prepared statements only. Values go into SQL through the tagged forms, never by building strings. See Tagged SQL.
- HTML is escaped. Astro and React escape values for you. Never
innerHTMLorset:htmlwith data unless it went throughsanitizeHtml(). - Links people type are parsed with
new URLand allowed only withhttps:(andmailto:where it fits).
Passwords and email addresses
Not in any form (plain, hashed, encrypted or masked), not to anyone, not even to its own user: no response, page, email, log, export, cache or URL carries one. A forgotten password gets a reset link from the site's sign-in. The same goes for sign-in codes, tokens and keys once issued.
An email address is seen only by its own user and the site owner (or staff with permission to see people's details). Everyone else sees a name, or nothing.
| Where | What it shows |
|---|---|
| Public pages, public routes, sitemaps, share tags | No email addresses at all |
| Lists, comments, reviews, “posted by”, team members | A name or initials |
| Someone must recognise an address that isn't theirs (an invite already sent) | Masked: m•••@gmail.com |
| Errors and form messages | The same answer whether an address exists or not |
| Logs, URLs, background job data, cache keys | A user id, never the email |
Store only what your app needs. Point at people by user id rather than copying their email or name into your tables, and say what you store in your listing's data note.
The site owner and admins
The owner opens a site's admin with View as admin in the dashboard. It's the only way in, and only the account's owner can use it. Everyone who signs up is a normal user, whatever email they use. Team members get in by signing up on the app and getting a role from the owner.
- Never create an admin or owner, give out the owner role, or add another way in.
- The site doesn't know the owner's email, and their
viewer.emailis empty, so never assume a viewer has one. To email the owner, send kindnotificationwith noto. - Check permissions, never a role name or an email address.
- A duplicate raises
P2002, so catch it for a clean answer. Don't useString[]columns: use a related table, orJsonfor an opaque list.
Secrets
- No keys, tokens or passwords in your code, your repository or a file in your bundle. Bundles are scanned for private keys and live API keys, and one found is refused.
- The site's own secrets (
DATABASE_URL,SITE_KEYand the rest) are read only on the server, throughsrc/platform/config.ts, and never sent to a browser or a log. - Everything in a page or in browser code is public. No hidden admin routes and no secret flags.
Rate limits
Anything public that writes, sends email, costs money or can be guessed needs a limit. The site's built-in limiter covers its own sign-in only, so your app checks its own table, bounded and indexed:
// A public form: at most 30 sign-ups a minute for the whole site.
// Index (created_at) keeps this cheap. LIMIT 31, never count(*).
const recent = await db.$queryRaw<{id: bigint}[]>`
SELECT id FROM app_my_app_signups
WHERE created_at > now() - interval '1 minute'
LIMIT 31`;
if (recent.length > 30) {
return c.json(problem('rate_limited', 'Too many sign-ups right now. Try again in a minute.'), 429);
}For signed-in people, limit per user (user_id and created_at in the WHERE, with an index on both). A hard quota (seats, credits) is one conditional UPDATE, never a read followed by a write.
Performance
Sites run on Cloudflare Workers and reach their database over one connection per request. A query takes about 3 ms, but about 1 Worker instance in 15 is slow to reach the database, at about 170 ms a query. Fewer, smaller, indexed queries still matter, on an older site most of all, where each query is about 130 ms.
| Rule | Why |
|---|---|
| At most about 5 database round trips per request, ideally 1 to 3. | On a slow Worker instance, five sequential queries are already about a second. |
| No query inside a loop (N+1). | A list of 50 becomes 51 round trips. Use a join or one IN (…) query. |
| Independent reads in Promise.all. | Two reads at once take as long as the slower one, not both. |
| Select only the columns you show. | Less to read, send and serialize, and nothing private by accident. |
| A LIMIT of 100 or fewer on every read, with keyset pages. | An answer never grows with the table. |
| An index for every query shape. | Without one, Postgres reads the whole table. |
| Heavy work (big files, reports over many rows) in a background job. | A request has a short CPU budget. |
// WRONG: one query per booking (N+1). 50 bookings = 51 round trips.
for (const b of bookings) {
b.service = (await db.$queryRaw`
SELECT title FROM app_my_app_services WHERE id = ${b.service_id}`)[0];
}
// RIGHT: one query with a join.
const rows = await db.$queryRaw<Row[]>`
SELECT b.id, b.starts_at, s.title
FROM app_my_app_bookings b JOIN app_my_app_services s ON s.id = b.service_id
WHERE b.user_id = ${me}
ORDER BY b.id DESC
LIMIT 21`;
// RIGHT: reads that don't need each other run at the same time.
const [services, bookings] = await Promise.all([
db.$queryRaw<Service[]>`SELECT id, title FROM app_my_app_services ORDER BY title LIMIT 100`,
db.$queryRaw<Booking[]>`SELECT id, starts_at FROM app_my_app_bookings WHERE user_id = ${me} ORDER BY id DESC LIMIT 21`,
]);The Site Cache
Every database query is billed and a Site Cache read isn't. Fix the query first, then cache what many visitors read and that rarely changes (services, menus, opening hours, settings, totals). Before adding a key, list every place that changes that data and clear it in each one. Server code only, with cache.getOrSet(key, ttl, load).
import {cache} from '../../../platform/cache';
const DAY = 86_400; // seconds
// Read-through: the cached value, or load it, keep it for 7 days and return it.
const services = await cache.getOrSet('my-app:services', 7 * DAY, () =>
db.$queryRaw<Service[]>`SELECT id, title, minutes, price_cents FROM app_my_app_services ORDER BY title LIMIT 100`
);
// In the route that saves a service, right after the write:
await cache.del('my-app:services');- The TTL is required, in seconds, from 60 (1 minute) to 604,800 (7 days). Outside that range, the cache is skipped.
- Clear the key in the same route that saves the data. Freshness comes from the clear, not the TTL.
- Start your keys with your app's slug (
my-app:services) so they never clash with the site's. - A key is shared by every visitor: cache only what every visitor sees. Never sessions, roles, carts, prices an order charges, email addresses or anything secret.
- Values are JSON: dates come back as text, and BigInt values aren't stored.
| Data | TTL |
|---|---|
| Cleared by your own route when it changes (settings, services, menus) | 1 to 7 days |
| Changes by itself, nothing clears it (counts, “open now”, another service's data) | 1 to 60 minutes: as old as a visitor may see it |
| One signed-in person's own data | 5 to 30 minutes, under their own key |
| Money and access | Never cached |
Older sites may not have src/platform/cache.ts yet, and an app that imports it won't build there. The cache rules served on every site (/api/public/site/v1/agent-docs/cache.md) include a small fallback: keep it in your own folder (src/server/apps/my-app/) and import it from there.
Charge once, and never sell too many
For anything that charges, books or sells something limited. A double click or a lost connection sends the same request twice, and two people can reach the last item at the same moment.
import {idempotencyKeyOf, idempotent} from '../../../platform/idempotency';
// The same key again gets the first answer. A thrown error frees the key.
const key = idempotencyKeyOf(c.req.raw);
if (!key) return c.json({error: 'key_required'}, 400);
const {value} = await idempotent(
db,
{scope: `booking:${viewer.id}`, key, request: {slotId}},
tx => tx.booking.create({data: {slotId, userId: viewer.id}, select: {id: true}})
);
// One statement: 40 buyers for 5 items sell exactly 5.
const {count} = await db.product.updateMany({
where: {id, stock: {gte: quantity}},
data: {stock: {decrement: quantity}},
});
if (count === 0) return c.json({error: 'sold_out'}, 409);- Every charge and booking carries an idempotency key: one per attempt, made in the browser, the same on every retry. The site's own checkout already sends one.
idempotent()takes the key and does the work in one transaction, so a repeat gets the first answer. Only database work goes inside it, never a call to a payment provider.- The database decides what's left: one conditional update, a unique index, or a short lock. Never read, check in your code, then write.
- Never use the Site Cache for keys, locks or stock. It has no atomic operations, and a miss must not block a sale.
- Test it with many requests at once. 40 buyers for 5 items must sell exactly 5.
They have no idempotent(), and an app that imports it won't build there. Use your own table with a unique scope and key, and INSERT … ON CONFLICT DO NOTHING RETURNING id in a transaction. No row back means it's a repeat.
Never wait long on another service
- Every call to another service has a short timeout (a few seconds while a visitor waits), checks the answer and has a fallback: the section hides, the page still works.
- Fail closed: an error never grants access, marks something paid or skips a check.
- Slow or retried work (many emails, a long AI call, a sync) goes in a background job, not the request.
- Call a fixed https address written in your code. Never fetch a URL someone typed.
// A call to another service, from a request: short timeout, check the answer, fall back.
const res = await fetch('https://api.example.com/v1/rates', {signal: AbortSignal.timeout(3_000)})
.catch(() => null);
const rates = res?.ok ? await res.json().catch(() => null) : null;
if (!isRates(rates)) return c.json({rates: null}); // the section hides; the page still worksAccessibility and UI
- Every image has alt text, every form field a label, every button and link a name, and the page has a language. Review counts these: 3 or more issues fail.
- Show errors in words next to the form, in an element with
role="alert", and keep what the person typed. - It works at phone width and with a keyboard, with visible focus and enough contrast.
- Every colour and word the owner may change lives in your
settings.json, so a one-sentence customization can change it. - No inline event handlers or style attributes in Astro markup, and no scripts from another site: the site's Content Security Policy blocks them.
- Your demo page loads in 1.5 seconds or less on a slow phone connection (3 seconds is a warning).
Before you submit
This mirrors what the automatic checks, the code review and the reviewer look at (Review). Tick every line before you send your app:
- Your repository has ezystudio-app.json at the top, every file is under your own folders, and migrations follow the naming rule.
- Every route is public on purpose, or has withViewer and requireAuth, plus a permission for staff screens. Every read and write by id has the owner in the query and answers 404 for someone else's id.
- A test for each route that reads or writes by id: signed out 401, without the permission 403, another person's id 404, the owner 200.
- Every input is validated: type, trimmed length, allowed values, safe integers. Writes are built field by field.
- SQL only through the tagged forms. No $queryRawUnsafe, no SQL built from strings, a LIMIT on every read.
- No password in any form leaves the server. Another person's email appears only for the site owner and permitted staff; everywhere else a name or m•••@domain.
- No keys, tokens or passwords in the code or the repository.
- Public forms and anything costly have a rate limit, and answer the same whether something exists or not.
- At most about 5 database round trips per request, no query inside a loop, independent reads in Promise.all, and the demo page loads in 1.5 seconds or less.
- Read-heavy shared data is cached with a TTL chosen from the data, and cleared by the route that saves it.
- Every call to another service has a timeout and a fallback, and nothing waits long on one.
- Migrations run twice without errors on a throwaway Postgres, are forward only, and never drop or rename what the previous version reads.
- Every dependency has an exact version, no install scripts, nothing from git or a URL, and no high or critical advisories.
- Every image has alt text, every field a label, every button and link a name, and the page works at phone width.
- The data note says what you store and why, screenshots match the app, and prices in the app match the listing.
Database: Postgres and migrations, Security rules, and the full rules for coding agents on every site at /api/public/site/v1/agent-docs (security, performance, cache, database and review).