Skip to content

Security: seanoliver/sealed

Security

docs/SECURITY.md

Security Model

This app stores home addresses — of people who never signed up for it and can't audit it. Leaking them is the failure mode the architecture is built around. The security model does not rely on obscurity: every mechanism below is in this repo and covered by tests.

This document describes the model as implemented, the production checklist for deployments, and how to report a vulnerability.

Architecture: two independent walls

A leak requires two independent failures — a bug in server code and a bug in database policy.

Wall 1 — server-only data access. The browser talks to exactly two things: Next.js routes and Supabase Auth. The Supabase Data API (PostgREST) is disabled in production (exposed schemas removed — see the checklist), so the public surface of a deployment is Next.js routes, Supabase Auth, nothing else. All data access happens in server actions and route handlers via Drizzle over a direct Postgres connection (DATABASE_URL, server-env only).

Wall 2 — RLS enforced on every server query. Every table has row-level security enabled with default-deny, owner-scoped policies (books.owner_id = auth.uid()). Owner-facing queries never run as the table owner: withRls (src/lib/db/index.ts) wraps each one in a transaction that sets the user's JWT claims and drops to the authenticated role, so a forgotten where owner_id = … in app code hits the RLS wall instead of leaking data. The wrapper is integration-tested (src/lib/db/rls.test.ts), including proof that no role or claims leak onto the pooled connection after commit.

Untrusted surfaces: SECURITY DEFINER functions

The two unauthenticated flows — token updates (/u/[token]) and permalink self-adds (/b/[slug]) — go through four narrow SECURITY DEFINER functions in a private schema (supabase/migrations/00000000000002_private_functions.sql):

  • private.get_contact_for_token — token lookup, returns only the contact's own fields plus display metadata
  • private.apply_token_reply — applies a recipient's reply through a live link, re-gated against the book's enabled fields server-side. It writes personal_email to the link's own person only; there is no payload key for the other person's
  • private.submit_to_book — write-only permalink insert
  • private.check_rate_limit — Postgres-backed fixed-window rate limiter

All four have a locked search_path and EXECUTE revoked from public, anon, and authenticated — only the direct server connection can call them. Nothing else in any reachable role can touch update_tokens.

The admin connection that calls them (dbAdmin, which bypasses RLS) is lint-restricted: no-restricted-imports in eslint.config.mjs blocks importing db/admin outside an explicit allowlist of sanctioned call sites, each annotated with its rationale.

Least-privilege grants (and the TRUNCATE story)

RLS does not apply to TRUNCATE, and Supabase's default ACLs historically leave residual TRUNCATE/REFERENCES/TRIGGER/MAINTAIN privileges for client roles — meaning a compromised authenticated session could have emptied whole tables through enabled RLS. The core migration revokes everything from anon and authenticated (including via ALTER DEFAULT PRIVILEGES, so future tables are covered), then grants back exactly the DML the policies mediate:

  • anon holds zero grants on every table
  • update_tokens grants nothing to any client role — even the owner's SELECT throws permission denied
  • submissions has no INSERT grant — only private.submit_to_book can create one
  • email_sends and contact_events are read-only for owners

The pgTAP suite (supabase/tests/database/01_rls.sql) asserts the exact expected grant matrix — a stray grant (or a dropped one) in any future migration shows up as a named failure — plus cross-user isolation and WITH CHECK probes for every policy. Full story: docs/bugs/2026-07-11-residual-truncate-privileges-bypass-rls.md.

Database role

Local development connects as postgres — the table owner, so it implicitly bypasses RLS (which is what dbAdmin relies on) but also holds DDL, every schema, and role administration. Production SHOULD connect as a dedicated restricted role instead: it can read and write the app's tables and call the private.* functions, but owns nothing — no CREATE/ALTER/DROP, no ability to replace the SECURITY DEFINER functions or touch other schemas (storage, vault, …), and only a three-column peek at auth.users. A compromised app server then can't rewrite the schema out from under the security model.

Run this once against the production database as postgres (SQL editor or psql), then point DATABASE_URL at app_server:

create role app_server login password '<generate-a-strong-password>' bypassrls;
grant authenticated to app_server;
grant usage on schema public, private to app_server;
grant select, insert, update, delete on all tables in schema public to app_server;
grant usage on all sequences in schema public to app_server;
grant execute on all functions in schema private to app_server;
grant select (id, email, email_confirmed_at) on auth.users to app_server;
alter default privileges for role postgres in schema public
  grant select, insert, update, delete on tables to app_server;
alter default privileges for role postgres in schema public
  grant usage on sequences to app_server;
alter default privileges for role postgres in schema private
  grant execute on functions to app_server;

Notes (each verified against a local stack — the full vitest integration suite passes connected as app_server):

  • bypassrls is required, and does not weaken Wall 2. Without it, every dbAdmin query is silently filtered to zero rows by the default-deny policies (webhook status updates match nothing, token minting inserts nothing). It stays safe because RLS checks the current role: withRls executes SET ROLE authenticated, and authenticated has no BYPASSRLS, so every owner-facing query remains policy-enforced (src/lib/db/rls.test.ts proves both behaviors under this role). Supabase's postgres role is privileged enough to create bypassrls roles even though it isn't a superuser.
  • grant authenticated is what allows the SET ROLE authenticated inside withRls — and membership also inherits usage on the auth schema, which the column grant below needs.
  • select (id, email, email_confirmed_at) on auth.users is the only auth-schema privilege the app uses: owner-notify and Reply-To email lookups, and the confirmed-email check when a book invite links to a member. postgres can grant exactly this; broader DML on auth.users isn't grantable by postgres and isn't needed.
  • The schema has no sequences today (uuid keys throughout); the sequence grants plus the alter default privileges lines are future-proofing so tables, identity columns, or private functions added by later migrations (which run as postgres) keep working without re-granting.
  • Running the vitest integration suite against a restricted role additionally needs fixture-seeding privileges the app itself never uses (grant insert, delete on auth.users to app_server;, run as a superuser). That's for local verification only — don't add it in production.

Token design

Update links (/u/<token>) are bearer credentials, treated accordingly (src/lib/tokens.ts):

  • 32 bytes CSPRNG (crypto.randomBytes), 43-char unpadded base64url in the URL
  • SHA-256 at rest — the database stores only update_tokens.token_hash; a DB leak exposes no live links
  • 30-day TTL (TOKEN_TTL_DAYS)
  • Reusable until expiry — a recipient can reopen their link and change their reply until it expires (used_at records the latest reply). The row is locked (SELECT … FOR UPDATE) per reply, and a reply from a page opened before the same person's own later change is refused as stale. Anyone holding the URL can read and edit that contact until expiry, so a forwarded link stays live; the owner retires it with a re-send or an email change. Retiring a person takes clearing their personal email too, because invites go there first, and saving a cleared email with a personal one still set is refused until the owner clears it or confirms
  • Single-active-per-person — once a re-sent "Request addresses" email is delivered, every earlier link for that person is deleted, used or not. An owner changing a person's email also deletes all of that person's links; a recipient changing their own email keeps only the link they used. The same holds for a personal email, with one exception: adding the first one deletes nothing, because no address lost access and the link sent to the work address must keep working
  • Invites prefer the personal email — a request goes to the person's personal email when one is on file, otherwise to their email (src/lib/recipients.ts). A recipient adding one never touches email. Someone reading a work mailbox can open that link and read the personal address until the link expires, as with every other field on the link. A link only ever shows its own person's personal email, never the other person's
  • Shape gate before DB — both the page and the action test the token against TOKEN_SHAPE (^[A-Za-z0-9_-]{43}$) before any query; malformed tokens cost zero DB work
  • Never logged — the raw token exists only in the emailed URL; logDbError strips query params, and the sole exception is the EMAIL_DRY_RUN=1 non-production log, which is hard-disabled when APP_ENV=production
  • /u/* responses carry X-Robots-Tag: noindex, nofollow (next.config.ts) so a leaked link can't end up in a search index
  • Owner notification emails contain no address data, only a link to the dashboard. The notification for a token reply may name the household in its body so the owner knows who replied, but never in the subject line

Enumeration-proofing

  • Invalid, expired, and already-used tokens all render the same generic page — no distinction an attacker can use to probe for live tokens.
  • The personal email columns have no unique index, deliberately. contacts_book_email_unique would turn a recipient's reply into an error, or a visible difference, when the address is already in the book, so a reply stores the address the same way either way. Only owner and agent writes refuse an address another contact uses. Two contacts can therefore share an invite address; each gets its own email, because each envelope differs and skipping one would leave a contact with no link.
  • Permalink submits return an identical response (and take the same code path) whether or not the submitted email matches an existing contact. A match surfaces only to the owner, in the review queue. Name matches are worked out when the owner opens Review, so private.submit_to_book never compares names, and a name match can never bring an email. Sealed has no contact merge: values never move between contacts on a name match, because an email or address could then reach the wrong person.
  • The permalink page itself (src/lib/queries/public-book.ts) selects only the owner display name and enabled-field flags — never ids, counts, contact data, or profile details; the select's key set is asserted in tests so a widened query fails CI.
  • Auth failures on the entry page show one generic error, never Supabase's detail.
  • Requesting access returns the same "Your request is in" response whether the email has an account, is approved, or was just put on the waitlist, including when Supabase rate-limits or fails the email send.

Invite-only sign-up

Supabase Auth calls private.before_user_created_hook before it creates any user, whether through a magic link, Google, or the Auth API directly. The hook allows emails approved in private.waitlist, and records every other email as pending before rejecting the sign-up with the message sealed_waitlisted. The hook runs in its own transaction, so the waitlist row is kept even though the sign-up fails. Existing users never reach the hook. Only Google sign-ups start with a display name: private.handle_new_user reads user metadata only when the provider is google, and onboarding pre-fills the name only from a linked Google identity, so a direct signUp for an approved address cannot choose the name its recipients see. Waitlist emails are kept until deleted by hand; there is no retention period yet. Only ADMIN_EMAILS can approve emails, at /admin/waitlist; the waitlist is reachable only through private.* functions.

Approving an email sends a beta invitation with a sign-in link. The app generates it with auth.admin.generateLink (type magiclink), which creates the now-approved user if needed and sends no email itself; the app emails the link through Resend. This is the only use of SUPABASE_SECRET_KEY, which bypasses Auth checks: it is server only, read through src/lib/supabase/admin.ts, and lint-restricted to admin paths. The link expires after the project's OTP lifetime, 24 hours in hosted Auth and supabase/config.toml; an expired link lands on the entry page with a message to request a new one.

Sealed is passwordless, and a trigger on auth.users (private.strip_password) blanks every stored password. Without it, someone could sign up with a password for an approved email before its owner does, and keep signing in with that password after the owner confirms the email. Production must keep email confirmation on, so a password sign-up never gets a session before the owner confirms.

Known limits: calling the Supabase Auth API directly with the public anon key skips the app's Turnstile check and rate limit, so it can add pending rows and tell an approved or existing email (200) from any other (403). Supabase's own per-IP limits still apply. Supabase Auth captcha would close this; see #53. Response time also differs, because approved and existing emails wait for an email send and waitlisted ones do not.

Rate limits

All public surfaces are metered by private.check_rate_limit, a fixed-window limiter backed by Postgres (no extra infra). Client IPs and emails are hashed with HMAC-SHA-256 keyed by RATE_LIMIT_SECRET before use as keys, so private.rate_limits never stores them and a leaked table cannot be reversed by hashing guessed values. Each call also deletes rows older than two days, so the table stays small without a scheduled job; windows of two days or more are rejected. Callers fail closed: a limiter outage denies the request rather than waving it through.

Self-hosting note: the per-IP keys come from the x-forwarded-for header, which a platform proxy (Vercel, or your own reverse proxy) must set. Run the app bare — no proxy setting the header — and every visitor shares the single "unknown" bucket: the limiter still fails closed (legitimate traffic gets throttled long before an attacker gets extra budget), but the site degrades under any real load. Deploy behind Vercel or a proxy that sets x-forwarded-for.

Surface Key Limit
/u/[token] page view token-view:hmac(ip) 30 / hour
/u/[token] submit token-submit:hmac(ip) 10 / hour
/b/[slug] page view permalink-view:hmac(ip) 60 / hour
/b/[slug] submit, per client permalink-submit:hmac(ip) 5 / hour
/b/[slug] submit, per book permalink-book:<slug> 100 / day
Entry page request access, per client request-access:hmac(ip) 5 / hour
Entry page request access, per email request-access-email:hmac(email) 3 / hour

Accepted limits on the entry page:

  • Per-email lockout. Anyone who solves Turnstile three times in an hour with someone else's email blocks that email's sign-in link for the rest of the hour. Google sign-in still works for them.
  • Shared networks. Offices and carrier NAT share one per-IP budget of 5 requests an hour, and existing users sign in through the same form.

Ordering is deliberate: on permalink submits the per-IP limit runs first (a client burns its own budget before anything shared), and the per-book limit runs after the Turnstile gate — so bot-generated junk can never drain a targeted book's daily budget.

Bot protection (Turnstile)

Both public submit flows and the entry page's request-access form require a Cloudflare Turnstile pass. verifyTurnstile (src/lib/turnstile.ts) fails closed on every failure mode — missing secret, empty or oversized response, non-2xx from siteverify, malformed body, network throw — and never logs the response token (it's attacker-supplied and the siteverify payload can echo it).

Input handling and size caps

Every write passes through three independent nets:

  1. Zod schemas with max lengths mirroring the SQL CHECK constraints (src/lib/validation/*) — also keeping Postgres class-22 errors (which can echo input values into logs) unreachable
  2. The SECURITY DEFINER functions, which refuse non-object payloads and anything over 64 KB, whitelist the keys they map (junk keys never reach storage, including the audit trail), and re-gate optional fields against the book's enabled_fields server-side
  3. CHECK constraints on every column

Additional caps: CSV imports are rejected over 1 MB before parsing and capped at 1000 rows (re-validated server-side); the webhook route caps bodies at 64 KB (declared Content-Length and actual length). Submission payloads are attacker-controlled jsonb — the review queue renders them through a lenient display-only parser (unknown keys dropped, non-string values dropped, 200-char display truncation, malformed payloads get a reject-only card) and relies on React's escaping; dangerouslySetInnerHTML appears nowhere near user data.

CSV export: formula injection

A contact who sets their name to =HYPERLINK(...) via the token form must not become an exploit when the owner opens the export in Excel/Numbers/Sheets. Per OWASP CSV-injection guidance, contactsToCsv (src/lib/csv/export.ts) prefixes any cell starting with = + - @, tab, or CR with a neutralizing ' — a documented tradeoff (the ' survives re-import) of safety over losslessness.

PII-safe logging

  • logDbError (src/lib/log.ts) never logs a raw DB error: Drizzle's messages embed the full query with parameters, and Postgres detail can embed row values. Only the error code, constraint name, and the Postgres message (which names relations, not values) are logged.
  • The email layer never logs message bodies (they contain personal token URLs); real-mode failures log Resend's error name/message only.
  • Raw tokens, raw IPs, and Turnstile responses are never logged anywhere.
  • No PII in URLs or email subject lines.

Webhook verification

/api/webhooks/resend (src/app/api/webhooks/resend/route.ts) is the only unauthenticated write path outside the definer functions:

  • svix signature verification over the raw request bytes, before any parsing; bad or missing signatures → 401; missing server secret → 500 (never fail-open)
  • 64 KB body cap so unauthenticated input is never HMAC'd at megabyte scale
  • Verified-but-malformed payloads are acked with 200 (Resend schema drift must not cause retry storms) and the payload is never logged
  • A status precedence guard (sent < delivered < opened < bounced/complained) means retried or out-of-order events can only move a send's status forward — a late email.delivered never regresses opened, and nothing regresses a bounce

Audit trail

contact_events records every contact mutation — owner edits, token updates, approved submissions — with a source tag and a diff. The table is client-unwritable (no INSERT grant or policy; rows are appended server-side only), and the token-update path whitelists payload keys so junk from the untrusted payload never reaches the stored diff.

Production checklist

Work through this before pointing real traffic at a deployment:

  • Disable the Data API: Supabase Dashboard → Settings → Data API → remove all exposed schemas. Local dev keeps it on because the CLI and pgTAP need the stack; production must not. This is Wall 1 — do not skip it.
  • DATABASE_URL uses the transaction-mode pooler (port 6543) with sslmode=require, and lives only in server-side env (never NEXT_PUBLIC_*, never in client-reachable config).
  • DATABASE_URL connects as a restricted role, not postgres — create app_server with the tested SQL in Database role and point the connection string at it.
  • Enable Supabase Auth email rate limits (Dashboard → Auth → Rate Limits) — magic-link requests are an email-sending endpoint.
  • Auth settings note: the app is passwordless (magic link + Google), so minimum_password_length is N/A — but confirm signups stay confirmation-gated: the magic-link flow inherently verifies the email, and local config.toml sets enable_confirmations = false for dev convenience only. Review the hosted Auth email settings rather than copying the local config.
  • Real Turnstile keys — the keys in .env.local.example are Cloudflare's public always-pass test keys; with them, the bot wall is decorative.
  • Resend: verify the sending domain (SPF/DKIM) and set the webhook endpoint + RESEND_WEBHOOK_SECRET — without the secret the webhook route refuses all events (fail closed).
  • Vercel env vars marked Sensitive so they're write-only in the dashboard.
  • EMAIL_DRY_RUN must be unset. The code also gates it on APP_ENV !== "production" (a stray flag can't divert real sends into console logs of token links), but don't rely on the belt when you can remove the braces.
  • RATE_LIMIT_SECRET is a random production value (openssl rand -hex 32), never the example one. Changing it resets every rate-limit window.
  • X-Robots-Tag for /u/* ships in next.config.ts — verify the header survives any proxy/CDN in front of the app (curl -sI https://<app>/u/anything | grep -i x-robots-tag).

Reporting a vulnerability

Please do not open a public GitHub issue for security problems. Email hello@sealed.page with a description and reproduction steps; you'll get an acknowledgment within a few days. Coordinated disclosure is appreciated — this app holds other people's home addresses, so please allow a fix to ship before publishing details.

Design-decision records

The non-obvious security decisions are journaled with symptom, root cause, and guardrail — useful reading before touching the related code:

There aren't any published security advisories