Part of the Refresh Token Design series.
Two Correct Features
Branching says a refresh that cannot prove its device gets a new row of its own, copying the device key from the row it came from, opening at the lowest tier. The honest case is a browser whose storage was cleared, and its road back is to prove a second factor, which rebinds the row to a fresh key.
Per request signing says every request carrying a session must be signed by the device key on that session’s row.
Both were designed carefully. Both were reviewed. Both shipped in the same week.
They Cannot Both Be True
Read the two sentences again with one fact in mind: a browser that lost its key has a new key.
The branch carries the old one, copied from its parent.
So every route the branch exists to reach is signed with a key the row does not hold, and the signature filter refuses it before anything else runs. The routes that register the new key are gated on the key that was lost.
It is circular and it is obvious once written down, which is the uncomfortable part. Neither feature is wrong. The contradiction lives in the space between them, and nothing owns that space.
What It Actually Did
This was proved against the running service before anything was changed, on an account really holding an authenticator.
refresh signed by the new key -> 200 branch created, lowest tier
GET /me -> 401 SIGNATURE_INVALID
GET /mfa/methods -> 401 SIGNATURE_INVALID
POST /mfa/verify -> 401 SIGNATURE_INVALID
The branch is created correctly. It is then unusable by the only client that would ever hold one.
What the User Saw
Nothing dramatic, which is why it would have survived a long time.
The client refreshes once on a failed signature, as designed. That refresh cannot prove the device either, so it branches again. The retry against the new branch fails identically. The circuit breaker fires and sends the person to the login screen.
So the product still worked. Every storage eviction just ended in a full sign-in instead of typing one code, which is exactly the cost branching was built to remove, and each attempt burned a branch against the lineage cap on the way.
A feature that silently degrades to the behaviour it replaced does not generate a bug report. It generates nothing.
The Rule That Resolves It
The lowest tier is never asked for a signature.
Not as an exemption bolted on, but because that is what the tier means. A session is there precisely because it could not prove the device, and that tier exists to let it prove something else. Demanding the device signature to get in is asking for the thing whose absence put you there.
The parallel that makes it obvious: sign-in registers a device key and is signed by nothing, because there is nothing yet to sign with. The lowest tier is that same moment happening a second time. Above it, the key on the row is by definition the one the browser proved it held, so the check means something again.
What that opens is small and worth listing rather than waving at. Unsigned, the tier reaches which factor types the account holds, who the caller is, a code send that refuses outright for any account that can be branched at all, a verify that still needs a real factor code, and sign out. Somebody holding a captured refresh token gets a name and a list of factor types. That is the price, and it is the right one.
The Second Defect in the Same Read
Once you are looking at two features against each other, you find the others.
Collapse, the statement that ends every other row of a lineage, was guarded by a flag meaning “this lineage has been branched from”. The flag was written on the root when a branch was created. It was not written on the branch itself, and the guard is read off the row making the request.
So a branch that climbed back to full carried a false flag and collapsed nothing.
Which means the ejection worked in exactly one of the two orderings. Thief branches, real client stays on the root, root carries the flag, real client’s next request ejects the thief: correct. Real client branches, climbs back, and its own request checks a flag it was never given: nothing happens, and the row a copied refresh token was taken from survives with its own thirty day token.
The second ordering is the one branching exists for.
How It Was Found
Not by a failing test, because there were no tests for any of it. Not by using the product, because the product still worked.
By reading the week’s own work with the specific question “what does each of these assume about the other”.
That is a boring answer and it is the actual lesson. Both defects are invisible to anything except reading, because in both cases the system does something reasonable. One degrades to a full login. The other silently skips a cleanup. Neither throws, neither logs an error, neither shows up in a metric anybody would have.
The failure mode of a security feature is usually not a crash. It is the feature quietly not applying.
What the Tests Assert Now
The suite that should have existed got written after the fix, which is the wrong order but better than not at all. What it pins down:
- A correct signature is accepted, and unsigned, wrong key, wrong path, wrong query and a timestamp two minutes out in either direction are all refused.
- The identical headers sent twice are refused as a replay, and told apart from a bad signature.
- A refresh without a valid proof branches, and the row it was presented against is untouched.
- The branch reaches its climb-back routes signed by the new key, which is the regression this post is about.
- Proving a factor widens the branch and rebinds the key.
- The next request collapses the lineage, and the survivor keeps its original root.
- An account with no provable factor is refused rather than branched, and its session is left intact.
Three traps in writing it, all of which cost a debugging round and are worth knowing in advance.
A factor remembers its last accepted step. The code used to confirm enrolment cannot be presented again a moment later to verify, because replay protection on the authenticator is doing its job. The test has to clear that column, which is standing in for thirty seconds passing.
A tier check has to name a route the subject actually holds. Point it at something they were never granted and the gate answers on the grant before the tier is consulted, so the test passes while proving nothing about tiers.
The cap counts rows, not branches. The count is taken before the insert and includes the row being branched from, so a limit of ten means nine branches and the tenth refresh is the one refused. The first version of that assertion was wrong and the code was right.
The Takeaway I Would Want
Two security features that are individually correct can compose into a hole, and the composition is nobody’s code, which means nobody reviews it.
The only thing that caught it here was going back over a week of finished work and asking what each piece assumed about the rest. That is not a process anybody schedules. It is worth scheduling.