Building Back & Forth around COPPA
Have you noticed that almost every app says you have to be 13 or older to use it? Instagram, Discord, YouTube, TikTok, they all say it somewhere in the terms. It’s rarely because 13 is a magic age for anything. A big reason is COPPA, the US Children’s Online Privacy Protection Act. Once you know a child under 13 is using your service, a long list of obligations kicks in, and for most companies it’s easier to put a number in the terms and keep kids out than to meet them.
I went the other way. Back & Forth is a shared journal for parents and kids who live apart. A kid writes about their day, draws something, or records a voice note, and a parent or grandparent writes back. A lot of those kids are under 13, which puts the app squarely under COPPA. I’m not a lawyer, and this isn’t legal advice. It’s what I built after reading the rule closely, and the places where it changed the design.
The short version is that I thought COPPA was a checkbox. It turned out to be most of a backend.
The checkbox I started with
The first version of kid accounts had a parent tick a box that said “I am this child’s parent or legal guardian” and then create the account. When I went through the rule (16 CFR Part 312) against my own code, the top two problems were both critical:
- A checkbox isn’t verifiable parental consent. The rule asks for a method “reasonably calculated” to make sure the person consenting really is the child’s parent. Nothing tied my box to an adult. Web sign-up only checked an email address, so a 12-year-old could have signed up as a grown-up and made kid accounts for themselves and their friends.
- People who weren’t parents could attest to being one. The form had a relation picker with “grandchild” and “other” right above the sentence “I am this child’s parent or legal guardian.” A grandparent who picked “grandchild” was either stuck or had to tick something untrue, and my own form was inviting the false statement.
The cheap fix the FTC allows is called email plus: you email the parent and they confirm. But it’s only allowed when a child’s information isn’t disclosed to anyone, and the whole point of a journal is that a kid shares what they write with a grandparent, an aunt or the other parent. I took the conservative reading and went with one of the stronger methods: a signed form, a payment transaction, a call with trained staff, a government ID checked against a database. I’m one person, and I didn’t want to run a mail room or store anyone’s driver’s license.
Handing verification to Kids Web Services

I use Kids Web Services (KWS, from Epic Games) to verify parents. A parent can confirm they’re an adult with a card, a photo ID or the last four digits of their SSN. KWS handles all of that, and I only find out whether they passed. I never see the card or the ID, so I have nothing sensitive to store or leak.
Before a grown-up can add a kid or take one on as a co-parent, they have to be verified. Starting that creates a row in parent_verifications, and I tell KWS to email the address on their account, passing the row’s id along as an opaque “external payload.” KWS sends the result back two ways, a webhook and a redirect when the parent returns to the app, and whichever arrives first wins. The code in this post is simplified, and I’ve written the queries as plain SQL so it doesn’t depend on any one database library.
async function startVerification(user: User) {
const row = await db.one(
`insert into parent_verifications (user_id, email, requested_at)
values ($1, $2, now())
on conflict (user_id)
do update set email = excluded.email, requested_at = now()
returning id`,
[user.id, user.email]
)
await kws.sendEmail({
email: user.email,
location: 'US',
language: 'en',
// KWS gives this back untouched when it calls us
externalPayload: JSON.stringify({ id: row.id }),
})
}
Both callbacks arrive over the public internet, and either one flips someone from “can’t add a kid” to “can.” A forged one would defeat the whole feature, so they’re signed and I check the signature before reading anything else. The webhook’s x-kws-signature header is an HMAC-SHA256 of a timestamp and the raw body:
function validSignature(header: string | undefined, body: string, secret: string) {
if (!header) return false
const parts = header.split(',')
const t = parts.find((p) => p.startsWith('t='))?.slice(2)
// you get two v1= values while KWS is rotating the secret
const sigs = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3))
if (!t || sigs.length === 0) return false
const expected = createHmac('sha256', secret)
.update(`${t}.${body}`)
.digest('hex')
return sigs.some((sig) => safeEqual(sig, expected))
}
The signature is over the exact bytes KWS sent, so the webhook route has to be registered before express.json() and read the raw body, or the parsed-and-reserialized JSON never matches. safeEqual wraps crypto.timingSafeEqual. I also check that the address in the event is the one I asked KWS about, and refuse to mark anyone verified if it isn’t.
The detail I’d have gotten wrong in a hurry is that both callbacks can fire, in either order, more than once. The update that marks someone verified only touches rows where verified_at is still null, and the follow-up work only runs for the callback that actually changed the row:
async function markVerified(id: string, via: string) {
// The "verified_at is null" is the whole trick. The second callback
// finds nothing to update, so nothing below runs twice.
const rows = await db.query(
`update parent_verifications
set verified_at = now(), verified_via = $2
where id = $1 and verified_at is null
returning user_id`,
[id, via]
)
if (rows.length > 0) {
await sendKidNotice(rows[0].user_id)
}
}
Without that guard, every parent would get two emails.
Only a parent can make a kid account

The grandchild problem had the simplest fix. I removed those relations from kid creation. A grandparent’s path is now to ask the parent to make the account and connect them, which the app already supported. The same rule covers co-parent invites: only a parent or legal guardian can accept one, and the wording they agree to says so. I could have kept the box and added a note, but then I’d have known about the false statements and shipped them anyway.
The kid never needs an email address

Kids don’t have email addresses, so there was nothing to collect. A parent adds their child, and the app produces a setup link. The kid opens it and picks their own username and PIN. The parent’s consent is recorded when the link is made, before the kid has typed anything.
There’s a wrinkle in split families. A kid might already have an account made by their other parent. In that case the second parent’s link only asks to join it, and one of the kid’s current managers has to approve, so a link from a stranger can’t take over a child’s account. If the other parent declines, or never answers, the link still works for a separate account. I didn’t want one parent’s silence to block the other.
Consent you can trace to the exact words

Verification tells me who consented. I also need to know what they consented to, and “the parent agreed” isn’t an answer if I can’t say which text they saw.
So every version of the consent text lives in a consent_versions table, word for word, and a database trigger refuses any update or delete on it. Changing the wording means adding a new row in a migration, and a test fails if the wording in the code has no matching row. A second table, kid_consents, logs every agreement: which kid, which parent, which wording, when, how, and the KWS reference for that parent’s verification at the time. It’s append-only as well. Two choices there are worth explaining:
- There are no foreign keys from the log to people. If a parent deletes their account, or a kid is deleted, the proof that consent was given should survive. The log holds ids and timestamps, never names or content.
- A wording can be marked “material.” If I change what’s collected, used or disclosed, every parent who agreed to the older version is asked again, and the screen lists what changed. Fixing a typo doesn’t trigger that. Adding a new kind of data would.
What the parent can do afterwards

COPPA gives a parent three rights after the fact: to see what’s been collected about their child, to stop further collection, and to have it deleted. Each one turned out to have a catch.
Reviewing. A kid might write in several journals, including ones with a grandparent that the parent isn’t in, and the parent has the right to all of it. A parent asks for a copy from the kid’s page, and it arrives in my admin inbox as a request. I only see counts, never the content, so I’m confirming the request is reasonable without reading a child’s journal. Once approved, the parent downloads a zip for seven days. I don’t store it. The server builds it from the live data on each download, so no extra copy of anyone’s journal is sitting around.

Withdrawing consent. In a split family I can’t make one parent wait on the other. Either parent can withdraw their own consent: they stop managing the kid, the journal that was just the two of them is deleted, and the withdrawal is logged. The one limit is that the last remaining parent can’t withdraw, because then nobody would be responsible for the account. For them the option is deleting it.
Deleting. A parent who isn’t the account’s owner can ask for deletion. The other parents are told right away, and the account is deleted after seven days unless the parent who asked cancels. The delay means nobody can silently remove a kid’s whole account in the middle of a custody disagreement.
Not keeping kids’ data forever

The 2025 amendments to the rule (compliance was required from April 2026) say operators have to publish a retention policy and can’t keep children’s information indefinitely. I had none, so a kid who stopped using the app would have had their data sitting in my database for years.
Now each kid account tracks last_active_at, bumped at most once a day when the kid signs in. After 18 months of nothing, every parent on the account gets a warning, and a reminder a week before the end. Thirty days after the warning the account is deleted, unless a parent chooses to keep it or the kid signs in. Each step of the scheduled job claims a row with a conditional update before it sends anything, so if two backend instances run at once, only one sends the email.
Smaller things that added up

- A direct notice. The rule requires telling the parent what’s collected before collecting it. When a parent finishes verification, they get an email about the children’s privacy notice before any kid exists, and another naming the kid once an account is set up.
- No kids’ names where strangers can see them. Push notifications and email subjects used to say things like “Quinn wrote something.” Lock screens and inbox previews are visible to anyone nearby, so subjects now say “your kid” or “your kids,” and the visit reminder asks for a favorite moment without saying a visit just ended.
What I’d tell someone starting out
Read the rule before you build the account system. Most of what I changed was design work, and it’s much easier to get the data model right once than to add consent logs and a retention job under a live app. The decisions with the biggest effect were handing verification to a third party, making consent text and logs append-only, and giving a kid’s data an end date.
I also wouldn’t skip the lawyer part. I wrote up my own analysis of each gap, with the rule citations, as prep for counsel, and I’d do that again. None of it replaces a lawyer, and I don’t treat my own reading of the rule as the last word.