The constraint that shaped everything
I wanted accounts and sync without paying for or babysitting a backend. The usual answer is a small API server: auth middleware, an ORM, a hosting bill, a thing to patch. I wanted none of it. The constraint became the design: if there is no server, the database itself has to be the API, and the database has to enforce every rule.
Neon makes that possible. Its Data API exposes Postgres over REST (PostgREST), and Neon Auth issues the identity. So the browser talks straight to the database: reads are table GETs filtered by row-level security; writes are POST /rpc/… calls to Postgres functions. The front end is a single static app.html on a CDN. There is nothing in between to run.
Authorization is the architecture
With no server to check permissions, every rule lives in Postgres. Reads are governed by row-level security: a policy on each table decides what the signed-in user can see (their own profile; teams they own or joined; schedules assigned to them or in a team they own). A user simply cannot GET another tenant’s rows. The database refuses.
Writes use a two-layer pattern I’m proud of. The only functions the Data API exposes are thin SECURITY INVOKER wrappers in the public schema. Each one reads the caller’s identity once, then delegates to a privileged SECURITY DEFINER worker in a separate kc_private schema that is not exposed by the API. The workers do the real work but trust only the user id the wrapper hands them. A column-level grant even hides a team’s secret invite token from ordinary SELECTs. It is genuine privilege separation, written in SQL rather than a framework, and I verified it live with negative tests: RLS isolation between users, and confirming the private workers are unreachable through the API.
If there’s no server, the database is the API, so the database has to be the security boundary too.
Gotcha 1 · the signed-in user was NULL inside my own functions
The first version put the logic directly in SECURITY DEFINER functions and asked Postgres “who is calling?” It always answered NULL. On this Neon project the JWT identity only resolves while running as the authenticated role. Inside a SECURITY DEFINER function, which runs as the table owner, both auth.uid() and the request’s JWT claims are empty. A brand-new signup couldn’t even create its own profile row.
The fix is the invoker/definer split above: read the identity once, in the invoker context, via a current_uid() helper (Neon’s auth.uid() with a fallback to the sub claim so it needs no grant on the auth schema), and thread that id down to the privileged worker as an argument. Identity is captured in the one place it’s trustworthy and passed explicitly everywhere else.
Gotcha 2 · iOS quietly logged everyone out on refresh
Login worked, then a page refresh signed you out, but only on Safari and iOS. Neon Auth’s session cookie is set by the neon.tech host, which is third-party to the app’s domain, so Safari’s tracking prevention dropped it. Moving to a same-site subdomain still failed: iOS WebKit won’t persist a cookie for a host the user never visited at the top level.
The fix was to make the cookie first-party by routing auth same-origin: app…/__neonauth is rewritten to a 57-line Cloudflare Worker that proxies to Neon, so the cookie is set on the app’s own origin and every browser keeps it. The Worker also does a few non-obvious header transforms (stripping x-forwarded-host, which otherwise makes Neon reject the request). Hiding under it was a client bug that masked the whole thing: the session-restore call was gated on a bearer token that Neon never sends, so it never ran. The cookie was there, just never checked. Now the app always restores from the cookie on load.
Why these two were worth writing down
- They’re invisible in the happy path. Both passed a casual test on desktop Chrome and failed only in production, on specific engines, under specific policies.
- They’re the price of “no server.” Pushing auth and authorization to the edges of the stack means learning exactly how JWT context and browser cookie policy behave, knowledge a middle-tier server would have hidden.
Hardening it like a product
A live app with cloud sync deserves more than a happy path, so I went back and closed the gaps a reviewer would ask about:
- Tests & CI on a build-stepless app. A dependency-free harness loads the single inline
<script>in a Nodevmbehind a DOM stub and asserts the load-bearing pure logic (time math, data migration, the share-link round-trip). GitHub Actions runs it on every push. - Honest sync state. The client no longer swallows save failures; a persisted dirty-set tracks unsynced edits, the indicator shows synced / offline / failed with a retry, and a pull can’t clobber an edit that hasn’t landed yet.
- Optimistic concurrency. Two devices editing the same week used to silently overwrite each other. Saves now carry the last-seen
updated_at; if the row moved on, Postgres raises a stale write and the client reloads the winner instead of clobbering it. - Defense in depth at the DB. Because any authenticated caller can hit the RPCs directly, the save functions validate payload shape and size in SQL and cap free-text lengths. The client can’t be the only thing keeping data sane. All of it has its own SQL test suite.
What it demonstrates
- Postgres authorization engineering: RLS policies,
SECURITY INVOKER/DEFINERprivilege separation, an unexposed private schema, and column-level grants: real multi-tenant access control written in SQL, not bought from a framework. - Web-auth internals: HttpOnly cookie semantics,
SameSite/Partitioned, Safari ITP and iOS WebKit persistence rules, and JWT minting with refresh-on-401. - Edge/infra composition: a Cloudflare Worker reverse proxy with surgical header rewriting, host-based rewrites, and custom DNS across providers, deliberately kept to $0/month.
- Product judgment: knowing which corners to leave (email verification off for a private beta) and which to harden (never silently lose a user’s saved hours).
The one place this design would ever add a server is a Stripe webhook receiver: the single seam where money, not data, has to cross. That’s a deliberate line, documented, not an accident.