Security, privacy, and compliance

Security.

This is the whole security picture in plain words. Nothing here is simplified to the point of being untrue. Where we use a technical name, it is the real one, so an engineer can search our code for it.

The one big idea

Almost everything below comes from one rule. Never trust anything the browser says. Check it again on the server.

A web page is code running on someone else's computer. Anyone can change it. They can turn a hidden button on, change a number, or skip the page entirely and talk to our server directly. So we assume all of that has already happened, and we check everything again on our side where nobody can reach the code.

If you only remember one thing from this page, remember that a button in our app never decides anything.

The lock on every table

Our data lives in a database made of tables, like spreadsheets. Every single table has a lock on it. The technical name is row-level security, and it means the lock works on each individual row rather than on the whole table.

So the database itself refuses to hand over a row that does not belong to whoever is asking. Not our code. The database. If we wrote a bug tomorrow that asked for every family's wallet, the database would still only return yours.

How it decides what belongs to you

Not by a company or group number. By the actual relationship. You are linked to your child by a row that says so. If that row exists, you can see that child. If it does not, you cannot, and there is no setting that changes it.

The two exceptions, named

Two things are not locked to a person, and we would rather list them than have you find them.

  • A map lookup cache. It stores the coordinates of place names so we do not look up the same city twice. It can be read and not written. It contains no personal data.
  • Sign-up forms on our marketing pages. Anyone can add a row, because that is what a sign-up form is. Nobody can read any of them back. Not even the person who submitted one.

Three keys, three jobs

There are exactly three ways our software can talk to the database, and they have very different powers.

  • Key
    The browser key
    Where it runs
    In your web browser
    What it can do
    Almost nothing on its own. Every request it makes is filtered by the lock. It is safe for it to be public, and it is public.
  • Key
    The server key
    Where it runs
    On our server
    What it can do
    Acts as you. Reads your session cookie and then gets exactly what you would get. The lock still applies.
  • Key
    The master key
    Where it runs
    On our server only
    What it can do
    Bypasses the lock entirely. Used for background jobs like the erase pipeline. It must never reach a browser.

The master key lives in one file and nowhere else. Any file that runs in a browser is not allowed to mention it. That rule is checked when we review changes.

A real outage we caused, written down

In May 2026 we broke parent sign-up for eleven days. Here is what happened, because a team that hides its own failures is a team you cannot check.

One lock rule said: to see a person, look at the parent-child links. Another lock rule said: to see a parent-child link, look at the people. Each rule needed the other one to answer first. The database spotted the loop and refused to answer at all.

The fix, and now the rule we never break: when a lock rule needs to look at another table, it goes through a small named helper that is allowed to answer without triggering the loop. The technical name for the helper is a SECURITY DEFINER function.

How to read this

This is not a live weakness. It is a fixed bug with a written post-mortem kept in our code. We include it because a reviewer learns more from how a team handled a failure than from a list of things that never broke.

Secrets

A secret is a password our software uses to talk to another company, like Stripe or our email service. There is a short written list of them and which single file is allowed to use each one. None of them may appear in anything that runs in a browser.

One secret we made stricter

One part of our site signs a cookie with a secret. It used to fall back to the master database key if its own secret was missing. That looked convenient and was actually dangerous, because the master key gets changed on a schedule we do not control, which would have silently broken every signed cookie in the middle of people's sessions.

We removed the fallback in May 2026. Now the app refuses to start if that secret is missing or too short. A misconfigured deployment fails loudly at the door instead of running in a half-broken state nobody notices.

Messages from other companies

Stripe and our email company send us messages. Anyone on the internet can send us a message that claims to be from Stripe. So we check.

  1. 1

    We read the message but touch nothing.

    No database write happens yet. Not one.
  2. 2

    We check the signature.

    Every real message carries a signature made with a secret only we and Stripe know. If it does not match, we answer with an error and stop. Nothing is written.
  3. 3

    We check we have not seen it before.

    We write down the message's unique number first. If it is already there, we say thanks and do nothing. A message delivered twice never has its effect twice.
  4. 4

    Only now do we act on it.

    If acting on it fails, we clear the note so the sender's automatic retry actually works instead of being ignored as a duplicate.

Our email company's messages use the same idea. If we are missing the secret needed to check them, we answer with a clear refusal rather than a server error, so the sender stops retrying forever.

Records nobody can change

Two sets of records can be written and read, and never edited or deleted by anyone signed in to the app. Not by a parent, not by a coach, not by an administrator.

We want to be exact about the limit of that, because it is the kind of claim a reviewer should test rather than take on faith. The protection is enforced by the database against people who are signed in. It does not bind the key our own servers use, which by design sits outside these rules. So this is a guarantee about users, and it is not a guarantee about us.

What covers our side is evidence rather than permission. The activity record is copied every night into a separate store the application cannot write to, so a later change to the original would show up as a difference between the two. On 2026-08-09 we also began verifying the user-facing half against the live database instead of asserting it: a test signs in as a real account, attempts to edit and delete these records, and then re-reads them to confirm nothing moved.

  • The activity record. Every important action writes a line: who did it, who it was about, what it was, when, from what address, and in what browser.
  • The money ledger. Every line in and out of the e-wallet.

The way this works is worth understanding, because it is stronger than a setting. For anyone signed in, our database only does what a written rule permits. If no rule mentions editing, editing is impossible, and there is nothing for someone to accidentally switch back on.

The two records got there by different routes

For the activity record, a rule to edit or delete was never written in the first place. The migration that created it adds a rule for writing a line and a rule for staff to read lines, and then stops. The absence is the protection.

For the money ledger, editing and deleting were once allowed, and a later migration dropped both rules. That is why the two live in different files, and a reviewer checking only one of them will think we have not covered the other.

The activity record is also copied every night to separate storage, compressed, which only staff can read. That copy is kept for seven years.

One honest detail about the ledger

Nobody can edit or delete a ledger line. But the permission to add a line still exists for a signed-in family member, left behind from an older version of the app when we removed the other two. A parent holds it for any child linked to them.

So the accurate phrase is that the ledger cannot be rewritten, not that only our server can write to it. The lines that matter are all written by our server, and the approval checks described on the money page are what decide when real money moves. But "append only" sounds tighter than what is actually true, so we are spelling it out rather than letting the phrase do work it has not earned.

How things fail

Different parts of a system should fail in different directions. Getting this backwards is how most security holes are made, so we decided it deliberately for each one.

  • When this breaks
    The AI safety check
    What happens
    Blocks the text
    Why
    A broken safety check must never wave something through.
  • When this breaks
    The permission check for a child under 13
    What happens
    Refuses to continue
    Why
    If we cannot prove permission was given, we act as if it was not.
  • When this breaks
    The setting that says what our own web address is
    What happens
    The code stops
    Why
    A missing setting must not turn into an open redirect.
  • When this breaks
    The counter that slows down repeated requests
    What happens
    Lets the request through
    Why
    On purpose. It is there to stop abuse, not to decide who may see what. A blip must never lock a real family out.
  • When this breaks
    The check that counts your data-export requests
    What happens
    Lets the request through
    Why
    Getting a copy of your own data is a right. Infrastructure should not be able to block a right.

The two that let requests through are deliberate, and they are safe for one specific reason: neither of them decides who is allowed to see anything. The real gates are the lock on every table, the permission checks, and the payment checks. We publish this because a reviewer who found a fail-open counter without being told would reasonably treat it as a finding.

Checking what comes in

  • Every form and every request is checked against a written shape before we do anything with it. Wrong shape, refused at the door. The tool we use is called Zod.
  • We never build a database question by gluing text together. That is how the classic database attack works, and the technique is simply not used anywhere in our code.
  • We never put text from a person straight into a page. The one place we render written content, it comes from our own editor and is cleaned on the server first.
  • Every email escapes what a person typed. Someone putting code into their own name cannot make it run in another person's email program.
  • We never run text as code. No part of the app takes something a person typed and executes it.
  • Requests from other sites are rejected. Our framework handles this for normal actions, and the one sensitive route that is not a normal action checks the origin itself on top of checking the session.

A sweep we ran on ourselves

In May 2026 we went looking for places where signing in as one person let you act as another. We found eleven and fixed all eleven. The pattern was always the same: a request took an identifier and trusted it. Now each one checks that the identifier belongs to whoever is asking.

Two are worth naming. Anyone could once trigger a scoring action for any child. And a second parent could once start the deletion of a child, which only the primary parent should ever be able to do.

Who runs the machines

We do not run our own servers or our own database. There is no computer in an office somewhere with our data on it.

  • What
    The database and sign-in
    Who runs it
    Supabase
  • What
    The website itself
    Who runs it
    Vercel
  • What
    All money and bank connections
    Who runs it
    Stripe
  • What
    Email
    Who runs it
    Resend
  • What
    Background jobs
    Who runs it
    Inngest

Each of these companies keeps its own security certification. That means the hardest parts of infrastructure security are handled by companies whose whole business is handling them, and audited by people who audit that for a living.

Production releases never happen on their own when we change code. A person has to decide to release. There is no automatic path from a code change to the live site.

Rough edges we know about

Four things we would tell an auditor in the first meeting, before they asked.

  • Our break-in tests were not real until 9 August. Thirteen tests tried to reach data they should not. One signed in as a real user. Seven were empty placeholders, four could not fail whatever the database did, and one merely checked that some settings were present. Worse, without credentials they reported success rather than a skip, so a green result did not prove they had run. All fifteen now sign in as real people, run against the live database, and go red if they cannot reach it. The first honest run was eight passes and five failures, and every one of those five is written out on the compliance page.
  • Our retention clocks are written down, not enforced. Three years for permission records and seven for money records are policy. No job deletes them when the clock runs out. What the code does today is strip the personal parts out immediately.
  • Nobody outside this company has checked any of it. No outside lawyer has reviewed these pages, no security firm has tried to break in, and our children's privacy certification is written and ready but has not been submitted. Our founder signed off. That is the whole of the outside review so far.
  • About eighty places use a shortcut we banned. Our own coding rules forbid quietly substituting a default value when data is missing, because it hides bugs instead of showing them. They are left over from earlier work. None of them is a security hole, and we are clearing them file by file rather than in one risky sweep.