The problem
A small restaurant needs more than a menu page: table reservations, event quotes, a photo gallery and news, all editable without calling a developer.
Public forms on a small business site are also the first thing bots find, so security had to be part of the build, not an afterthought.
From a visitor form to the owner inbox
- 01Visitor form
- 02Zod validation
- 03Rate limiter
- 04Supabase + RLS
- 05Email to owner
Security built in
| CONTROL | LAYER | PROTECTS |
|---|---|---|
| Row-level security policies on every table | Database | Data access |
| Admin area behind authentication middleware | App | Back office |
| Schema validation on every form input | API | Injection, junk |
| Rate limiting on public endpoints | API | Spam, abuse |
| Service-role key kept server-only | Secrets | Key exposure |
| Content-Security-Policy and security headers | HTTP | Clickjacking, XSS |
Straight from the code
Excerpts from the actual repository and deliverables, trimmed for length.
ALTER TABLE public.reservations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "public can insert reservations"
ON public.reservations FOR INSERT
TO anon, authenticated
WITH CHECK (true);
CREATE POLICY "admins can manage reservations"
ON public.reservations FOR ALL
TO authenticated
USING (is_admin());Row-level security: visitors can only insert a reservation, never read one. Reading and managing is reserved to admins.
import { RateLimiterMemory } from 'rate-limiter-flexible'
// NOTE: In-memory limiters reset on cold starts in serverless environments.
// For production, swap to RateLimiterRedis or RateLimiterPostgres.
const limiters = {
reservation: new RateLimiterMemory({ points: 5, duration: 3600 }),
devis: new RateLimiterMemory({ points: 3, duration: 3600 }),
contact: new RateLimiterMemory({ points: 5, duration: 3600 }),
} as constPer-endpoint rate limits on the public forms. The comment flags the known limitation, picked up in the lessons below.
Results
What I'd do differently
Clients do not ask for row-level security, but they suffer when it is missing. Next: replace the in-memory rate limiter with a shared store so it survives scaling.