A code on a screen that gives a tablet a child's session
A test harness could not photograph one screen, on purpose. It shows the credential that pairs a tablet to a child, and that changes how it is stored.
Anyone who can photograph a pairing code can pair their own tablet as a specific child. In a product used by children, that is a safeguarding failure and the kind of thing a security review or a customer’s audit asks about directly. Whether the code was treated as a credential or as an ID is what decides it.
It surfaced in an odd way. A screenshot harness swept forty-seven screens of the app and came back with one it could not capture. The first assumption was that the harness had a stale route in it, which had already happened once that morning. It had not. That screen displays the pairing code as a QR square and sets a window flag telling the platform it must not be captured, whether by a screenshot, a screen recorder or the operating system’s own thumbnailer. The screen was refusing, on purpose, for the right reason.
What was actually going on
A credential and an identifier are different kinds of string, and almost every property you would choose for one is wrong for the other. An identifier names a row. It is meant to be stable and shared, and to appear in URLs, logs and support conversations. A credential grants something. It should be short-lived, single-use where possible, revocable, never logged and shown no longer than needed, because possession is sufficient. Guessing it is the attack. The two are conflated constantly because both are opaque strings sitting in a column, and columns do not carry intent.
The conflation nearly landed during a change that moved every primary key, forty columns in all, to a time-ordered UUID. Two were deliberately left alone.
The pairing code is six characters that a child reads off a parent’s screen and types into a tablet when the camera path fails. It is short because a child types it, and drawn from an alphabet with the ambiguous glyphs removed, because a zero reads as an O and a one as an l. It expires after ten minutes, and generating a new one invalidates the previous one at once, so at most one live code exists per child. None of those properties survive being turned into a 128-bit key. The invite token is the string in a link sent to a second parent: long, unguessable, single-use and cancellable, and delivered through a channel the system does not control.
What we changed
Each is now a unique column beside a proper identifier, where before it would have been the identifier. The row has a stable, boring id that is safe to log, and a separate revocable, expiring credential. Cancelling an invite is now a state on a row that still exists, not a delete, which makes “cancelled” and “never existed” distinguishable. Those are two different answers for someone who clicks a dead link.
Four properties follow from calling a string a credential, and each was a real decision:
- It is stored hashed. The device token issued at the end of pairing is signed and kept as a hash, so the server can verify one and cannot reproduce one, and a database dump does not hand anyone a working session.
- It is scoped as narrowly as it is used: one child, in one household, and not a household credential that happens to sit on a child’s tablet.
- Revocation is an actual path, exercised against a live device to see what the child’s screen says.
- A token for a child who has since been archived is rejected at authentication, not resolved and then failed somewhere deeper for a reason nobody can read.
The harness’s finding was recorded as intentional in the sweep’s own output, with a note that the day it starts photographing that screen is the day to worry. A verification harness that meets a deliberate refusal should record it as an expected result, so its disappearance is a failure and not an improvement in coverage.
What it did not fix
The manual fallback for entering the code used a component that accepts only digits, while the code alphabet includes letters. The credential design was right and the keyboard disagreed with it, so the fallback was unusable for most codes. A constraint that lives in two places will be wrong in one of them.
Before the migration, the client made up the top-level identifier and the server accepted it. The server could never choose its format or guarantee its uniqueness, and every existing installation had already minted IDs in whatever shape the old client used. Removing that surface is what freed the schema. An identifier you did not mint is one you cannot change.
What to ask your own team or supplier
- For every opaque string in the schema, can someone say in one sentence whether possessing it grants anything?
- If it does, what are its lifetime, its revocation path and its storage form? Is it ever logged?
- Would a database dump hand over working sessions?
- Does the input method in the app match the alphabet of the code it accepts?
- Who mints each identifier, the server or a client?
Where this ends up
Identifiers, credentials and tenancy are among the decisions that are expensive to reverse once a product has users, which is why they are settled properly in a first version. The reasoning is set out under custom software development.