Part of the Refresh Token Design series.

The Question the Last Post Left Open

A refresh arrives carrying a device proof that does not verify. Two things can produce it, and the request looks identical either way.

A browser that lost its key and generated a new one, which is ordinary and frequent.

Somebody holding a copy of the refresh token, who cannot sign because the key never left the machine it was made on.

The server has to answer without knowing which. It must not lock out the first and must not serve the second.

The Obvious Answer Is Worse Than Nothing

The first version narrowed the session in place. Drop the row to the lowest tier, mint a pair against it, and let the caller climb back by proving a factor. The honest client proves one and carries on. The thief cannot, and sits at a tier that reaches nothing.

Narrowing was the visible half and it was fine. The minting is what broke it, for the reason the reuse detection post covers: the replay path hands back whatever the row currently holds, not what was presented.

So a thief who refreshed first was handed the newest pair rather than being refused, and the two parties converged exactly as before. Worse, every one of those attacker refreshes knocked the real person down a tier. They would prove a factor, climb back to full, and be dropped to the bottom again minutes later. A stolen token became a way to hold somebody in a permanent challenge loop.

The mistake underneath both is the same: a caller who has proved nothing was allowed to change a row they had not earned.

Do Not Touch the Row

A refresh that cannot prove its device changes nothing at all on the session it was presented against. Not the scope, not the tokens, not the expiry.

It gets a row of its own.

ALTER TABLE sessions ADD COLUMN parent_id uuid;
ALTER TABLE sessions ADD COLUMN root_id   uuid NOT NULL;
CREATE INDEX idx_sessions_root_id ON sessions (root_id);

The new row copies the user, the type and the device key, opens at the lowest tier with a pair of its own, names the row it came from in parent_id, and inherits root_id. A session opened by a login is its own root.

The device key is copied rather than dropped, and that is deliberate. The branch is the honest client’s road back: it proves a factor, and proving a factor is one of the three moments the server accepts a new key, so the row is rebound to the key the browser actually holds.

Now the two cases separate themselves without the server having to tell them apart. The honest client gets somewhere it can climb out of. The thief gets a row that reaches almost nothing, on a session the real person has never heard of, while their own session is untouched and still working.

Collapse Is the Ejection

Branching alone would just accumulate rows. What removes them is the moment a client shows which row it actually holds.

That moment already exists. It is the first use of a row’s current access token, which is where the rotation window closes.

DELETE FROM sessions WHERE root_id = :root AND id <> :keepId;

Every other row of the lineage goes in one statement. Above, below, and beside.

There is no tier check on it, and that was a correction. The first version allowed it only from the full tier, reasoning that a proved factor should earn the right to end the others. That was gating on a proxy. What is being rewarded is holding a row’s current pair, and presenting its access token is exactly that.

Whoever gets there first flattens the rest, which is a real consequence and worth stating plainly. Somebody holding a stolen refresh token can branch, use their branch’s access token, and force the real person through one sign-in. They also lose their own token doing it, because it belonged to the lineage that was just deleted, and the real person’s next login is a lineage they have nothing for. It is a self-destructing nuisance rather than persistence, and it costs one sign-in.

Three Things That Are Load Bearing

The lineage is capped. A caller who cannot prove the device can branch repeatedly, so the count is checked before the insert and the limit refuses further branches. The cap is an alarm as much as a limit, and it cannot produce a false logout, because refusing to branch takes nothing away from anybody.

Signing out deletes the lineage, not the row. Otherwise a branch the person never knew existed goes on refreshing for thirty days after they thought they had ended the session.

An account with no provable factor is refused rather than branched. This one took a reversal to get right. A branch exists so somebody who cannot prove the device proves something else instead. Where the something else is an emailed code, the weakest credential on the account becomes the recovery path for the strongest control on it, and a stolen token plus an inbox is permanently enough. So an account holding no enrolled authenticator gets an explicit “sign in again” and nothing is deleted, which means a later refresh carrying a valid proof still works and nobody holding a copied token can use the refusal to sign the real person out.

The argument against went: email is a factor everybody has, so nobody is ever factorless. That is true, and it is precisely why counting it was wrong.

Keep the Cleaner Off the Request Path

Collapse now runs on the first use of every current access token, which is nearly every request. The statement deletes nothing in the overwhelmingly common case, and you still do not want to issue it constantly.

Two guards, and they answer different questions.

A boolean on the row answers “is there anything to clean here”, set when a branch is created and free to read because the row was loaded anyway. The overwhelming majority of sessions have never branched and stop here.

A short lived cache key on the root id answers “have we cleaned this lineage recently”, so five requests on one pair run the cleaner once. Every refresh deletes that key, because a refresh is the only thing that can add a row to a lineage.

The direction of the fallback matters. If the cache cannot answer, treat the mark as absent and run the cleaner. Running it when it was not needed costs a statement that deletes nothing. Skipping it leaves a branch alive.

One More Thing About the Root

The survivor of a collapse keeps its original root_id. It is tempting to re-base it, null the parent and make it its own root, and the first version did.

That means the identifier a lineage is cleaned by changes every time it is cleaned. Keep it fixed and one value finds every row the line ever had, including after the row it names has been deleted. Nothing reads parent_id at all; it is a record of where a row came from.


Next: Signing Every Request, Not Just the Refresh