How it is actually built
Security
Effective 17 September 2026
This is not a trust-badge page. It is a description of the mechanisms, followed by a list of the things we do not have — because a small operation that only tells you the good half is not worth believing about the rest.
The short version
- One family never sees another’s data. Three independent layers enforce it and all three would have to fail at once.
- The database, not our code, is the last word. Row-level security is written to fail closed: a query that forgets to say which family returns nothing, not everything.
- Passwords are argon2id hashes. Sessions are random, stored only as a hash, and the cookie is locked to your family’s own address.
- Photographs are re-encoded on upload, which strips the camera data including GPS. Stripe holds the cards; we never see one.
- 764 tests run against a real database, and a lint fails the build if any route reaches the database without a family scope.
- What we do not have: no SOC 2, no third-party audit, one server in a house, backups on the same disk, one person operating it.
1. Keeping families apart — three layers
This is the product’s reason to exist, so it is the part with the most engineering in it. A breach between two families needs all three of these to fail.
- One door to the database. Every piece of route code reaches the database through a single function that opens a transaction and pins the current family’s identifier onto it. There is no other way in. The setting is transaction-local, so it cannot leak to whoever borrows that connection next.
- Row-level security, forced, and fail-closed. Every table that belongs to a family has PostgreSQL row-level security switched on and forced — forced means the rule applies to the database owner too, not just to the application. The rule is written so that an unscoped connection matches nothing: a forgotten filter returns an empty result rather than every family’s rows. Writes are checked the same way, so a forged family identifier in a request body is a database error, not an accepted row. A migration-time self-check refuses to apply any future migration that adds a family table and forgets its policy.
- Foreign keys that make the mistake unrepresentable. Every row that names a person points at the pair (family, person) rather than at a person. A message authored by someone in another family cannot be stored, because the database has nowhere to put it. The photograph table additionally has a constraint tying each file’s storage path to its own family’s folder.
The database role the application connects as cannot bypass row-level security and owns nothing, which is what makes layer two real rather than advisory. There is no bypass role anywhere in the system.
There is exactly one place in the entire product where anything crosses a family boundary, and it only writes: sending a photograph or message to a linked family copies it into their hub, after the link has been loaded in the sender’s own scope and found accepted. Nothing anywhere ever reads across a boundary.
Circles
Inside a family, a post can be addressed to a circle. That is enforced by a restrictive database policy layered on top of the family one. Restrictive is the operative word: such policies can only ever take rows away, so route code that forgets to say who is looking shows the everyone-posts and nothing else. The failure mode is missing data, never a leak.
2. Passwords, sessions and sign-in
- Passwords are hashed with argon2id (19 MiB of memory, two passes), the current recommendation for password storage. A hash is re-computed with stronger parameters automatically if the recommendation moves on. We never store, log or can recover the password itself.
- Session cookies carry 256 bits of random data and nothing else
— no user, no family, no signature to forge. They are
HttpOnly(script cannot read them),SameSite=Lax, and host-only: no domain attribute, so the browser itself refuses to send one family’s cookie to another family’s address, before a line of our code runs. Over TLS they are alsoSecure. - The database never holds a usable cookie. It stores the SHA-256 of it, so a stolen database dump yields nothing you can sign in with.
- Sessions are looked up inside the family’s own scope, so a cookie replayed against a different family is simply not found. They expire, are deleted on sight once expired, and a suspended member stops authenticating on their very next request.
- Signing in destroys the session the browser already held, so a session planted in someone’s browser cannot survive their login.
CSRF
Every request that changes anything must carry a token that matches the one stored with the session, compared in constant time. A missing or wrong token is refused. The token is handed out by the sign-in response, never by a cookie a form could replay.
Throttling and lockout
Failed sign-ins are counted two ways at once, per family: by the name typed (including one that does not exist, so the throttle cannot be used to discover which accounts are real) and by the IP address. Five failures in fifteen minutes locks that axis for fifteen minutes. The check runs before the password is verified, so a locked-out attacker gets no signal and costs us no work. A lockout in one family leaves the same person’s account in another family alone.
Related: a wrong password, an unknown email, an unknown member name and a suspended account all return the byte-identical refusal, and an unknown name still costs a full password verification so the timing matches. You cannot use the sign-in page to find out who exists.
Invitations
An invitation link carries 256 bits of random data. The database stores only its hash, it can be used once, it expires, and it is looked up inside the inviting family’s scope — so the same link opened on another family’s address finds nothing. A guessed link and an expired one produce the same page, and neither names the family.
3. The audit log
Each family has its own log, and it is visible to that family. It records sign-ins and failures, lockouts, password changes, invitations created, revoked and accepted, members added, suspended or re-roled, and every action an operator takes — with that operator’s verified email address. An operator resetting somebody’s password is a genuine account takeover; the family seeing it in their own log is what makes that acceptable.
4. In transit, and the browser’s own rules
- HTTPS everywhere, terminated by Cloudflare. The server reaches the internet through an outbound tunnel rather than an open port, so the house it sits in has no inbound hole.
- A strict content-security policy. Scripts may load only from
our own server — there is no
unsafe-inlineand no nonce, which means an injected<script>tag simply does not run. Frames are limited to six named embed hosts. The pages cannot be framed by anybody. - No third-party script in the hub, and exactly one on this public site. Nothing a family signs in to loads code from anybody but us — the policy above is what enforces it, so it is not a promise we can quietly stop keeping. The storefront carries Cloudflare’s cookieless page counter and nothing else, on its own widened policy that names that one host and applies to no other page in the product.
- Ordinary hardening on every response:
nosniff, a same-origin referrer policy, no caching of HTML, and HSTS once TLS is terminating.
5. Uploads and media
- Photographs are re-encoded through an image pipeline — decoded, orientation baked into the pixels, resized, written out fresh. The stored file is one our own code authored, so EXIF, GPS, IPTC and XMP do not survive. It also means a file that is not really an image is rejected rather than stored.
- Songs and clips are identified from their bytes, not from the
file name a browser claimed. A PNG called
clip.mp4is refused and nothing is written. They are served back with that sniffed type plusnosniff. - Uploads are streamed with hard caps, enforced as the bytes arrive rather than after: 10 MB a photograph, 40 MB a song (2 GB of music a family), 150 MB a clip (4 GB a family), and a 20 GB storage allowance for the hub, re-checked inside the database write so it cannot be raced.
- Media is stored outside the web root and served only through an authenticated, family-scoped route. The server refuses to start if the media directory is ever configured somewhere the static file server could reach it.
- Addresses a member types are guarded. Podcast feeds are the one place somebody can point our server at a host of their choosing, so: HTTPS only, the hostname is resolved and every address checked against the private ranges (a public name pointing at the home network is the classic trick), redirects are followed by hand with each hop re-checked, and the response is size-capped and time-limited.
6. Money
Stripe holds the cards. Payment happens on Stripe’s pages; card numbers go from your browser to Stripe and never reach our server, so there is no card data here to lose. We store a customer id, a subscription id and the subscription’s state. Stripe’s webhooks are verified by HMAC signature before a single byte of them is trusted, and each event is processed once.
7. The operator console
It lives on its own hostname behind Cloudflare Access, and the token Access issues is verified inside our own server rather than trusted because it arrived — signature, audience, issuer and expiry all checked. That matters because the machine also answers on the local network, where Cloudflare is not in the path. Every failure returns the same 404 as a hostname that does not exist, so the console cannot even be confirmed to exist.
It cannot read family content. No message, photograph, recipe or event is reachable from that host, and a test asserts the exact field names its data payload returns, so a future change that widened the shape would fail the suite rather than ship.
8. Backups
The database is dumped nightly at 03:30 and the media files are mirrored; 14 nightly and 8 weekly copies are kept. A restore was rehearsed on 31 August 2026 with every table’s row counts compared, and that rehearsal found a real defect — which is the entire argument for rehearsing it.
9. What keeps this true
- 764 automated tests, run against a real PostgreSQL database. Nothing about the isolation is mocked, because the isolation is the thing under test: the suite asserts that an unscoped connection reads zero rows from every guarded table, that one family’s queries return only its own rows even when explicitly asked for another’s, that a cross-family write is a database error, and that a cookie from one family does not authenticate on another.
- A lint that fails the build if any route, plugin or service reaches the database without going through the family scope. There is one documented exception — the Stripe webhook, which arrives with no host and no session and must find the family from the event before a scope can exist, after which it switches back.
- Migrations are immutable and checksummed. Editing one that has already been applied — even a comment in it — makes the migration runner refuse to start.
10. What we do not have
All of the above is true and none of it makes this an enterprise platform. Here is the other half, stated plainly so you can decide with it in front of you.
- No SOC 2, no ISO 27001, no penetration test, no third-party audit. Nobody outside has checked any of the claims on this page.
- One server, in a house, in the United States. No second region, no automatic failover, no load balancer. If that machine dies, your hub is down until it is fixed.
- The nightly backup is on the same physical disk as the database. It protects you against a mistake, not against the drive failing. There is no off-site copy yet, and this is the largest known gap.
- One operator. One person has the keys, the database and the disk. There is no separation of duties, because there is nobody to separate from.
- Your files are not encrypted at rest with a key we do not hold. Anyone with the disk can read them. Doing otherwise would mean a forgotten password destroyed the family album, which for this product is the wrong trade — but it means the honest statement is the one in this bullet.
- No two-factor authentication yet, and no password-reset email — the outbound mail is not wired up, so a locked-out member is reset by their family’s owner, and a locked-out owner by us.
- No formal uptime commitment, no status page, and no on-call rota.
11. Reporting a vulnerability
If you find something, please tell us. Email the address below with enough detail to reproduce it, and give us a reasonable chance to fix it before you tell anyone else.
We will not take legal action against you for a good-faith report, and we will not ask anyone else to. Good faith means: you stopped as soon as you had confirmed the problem, you did not read, copy, alter or keep any family’s data, you did not degrade the service for anyone, and you did not use the finding for anything other than the report. Within that, testing against the public site is fine by us.
We will acknowledge you within a few days, tell you what we found, and credit you if you want. We do not run a paid bug bounty.
Questions
Latch & Lantern is operated by The DAS Board LLC. Security reports and questions about this page go to [email protected].
Honest note. This page has not been reviewed by a lawyer or by a security auditor. It was written by the person who built the product, from the code, and it is a description rather than a guarantee. It is not legal advice. Every mechanism above is real; nothing above makes software perfect.