Part of the Refresh Token Design series.

The Fall-Through

The verify endpoint took a six digit code and worked out what it was. If the account had an active authenticator and no emailed code was currently live, check the authenticator. If an emailed code was live, check that instead.

It reads like a helpful convenience and it is the ceiling on the whole account.

Anything holding a session at the lowest tier can make an emailed code live simply by asking for one. So an account with an authenticator enrolled was still, at any moment of the attacker’s choosing, an account that could be entered with a code from the inbox.

Which means enrolling an authenticator bought nothing against the one adversary it exists for: somebody who has the password and the mailbox.

A second factor that a weaker factor can stand in for is not a second factor. It is a suggestion.

Three Things Move Together

The fix is one predicate, “does this account hold an active authenticator”, and three call sites that must change in the same commit.

Verify accepts the authenticator alone when one is enrolled. No fall-through.

The code send route refuses with a conflict for such an account, rather than accepting and quietly doing nothing.

The methods route lists the authenticator alone, so the client never offers the box.

Change one and you get a broken product rather than a secure one. Fix only verify and the client still shows an email option, still sends a code, and the person types it into a form that now refuses it. Fix only the send route and the client offers a button that errors. The three are one change.

The refusal is explicit rather than silent for that reason. A route that accepts and does nothing leaves the client with no way to know it should offer the authenticator instead.

The Half That Actually Matters

Of the three, “writes no code” is the one worth arguing for.

It would be easy to answer the conflict and still write the row, on the grounds that nobody can use it now anyway. Do not.

That send route sits at the lowest tier, so a stolen refresh token reaches it. If it kept writing codes, an attacker could cause a real, genuine, correctly formatted sign-in code to arrive in the victim’s mailbox at any moment, as many times as they liked.

That is not a leak. It is the setup for the phone call. “We are calling from support, we have just sent a code to verify your identity, could you read it back.” The code is real, the sender is real, the timing matches, and the person on the phone chose the moment.

An unusable credential arriving in somebody’s inbox on an attacker’s schedule is worse than no credential at all.

Which Settles a Question From Earlier

The tier post left something open. The code send route sits at the lowest tier, which is the tier a caller who could not prove the device lands on, and that looked uncomfortable.

The instinct was to raise it a tier. This closes it better, at the source.

For an account holding an authenticator, the route now writes nothing, so there is nothing to reach. For an account holding no authenticator, email is the factor, so nothing stronger is being undermined and the tier is exactly right.

Closing an on-ramp beats moving a fence.

Recovery Is a Person

Once an emailed code cannot complete a challenge, somebody who loses their authenticator can prove nothing at all. That has to have an answer, and the honest answer is that there is no self-service one.

The route is an administrative one that removes the factor. Three things about it are deliberate.

It asks the subject for nothing, unlike every other removal path in the system, which all require a code. That is the premise: they can prove nothing. What is proved is the actor, through the capability that gates the route.

It ends every session the subject holds, because the situation is that they have lost a device, and whatever is on it should stop working.

And afterwards the account is reachable by password and an emailed code and by nothing else, which is a deliberate step down that the person is expected to reverse by enrolling again.

There is no “recover with your backup email” and no printed recovery codes. Both are just another credential that can be phished or copied, which is the problem this post started with.

You Cannot Load a Row You Cannot Decrypt

One implementation note that looks like style and is not.

The authenticator secret is encrypted at rest through a column converter. The obvious way to remove a factor is a derived delete method, deleteByUserIdAndFactorType.

A derived delete is a select and then a delete. The select runs the converter over the secret.

So a factor whose secret cannot be decrypted cannot be removed, and the attempt fails as a 500 from inside the ORM. Which row is that? The one belonging to somebody whose secret became unreadable, which is exactly why they are locked out and calling for recovery.

The one route that exists to get people back into their accounts failed on precisely the accounts that needed it.

@Modifying
@Query("DELETE FROM UserMfaFactor f WHERE f.userId = :userId AND f.factorType = :type")
fun deleteFactor(@Param("userId") userId: UUID, @Param("type") type: MfaFactorType): Int

Nothing reads what it destroys. Worth checking anywhere a recovery path touches encrypted data, because the general shape is that recovery code runs against damaged state by definition, and anything that assumes the state is readable will fail when it is needed.

The Gap I Am Leaving Open

The only record a recovery leaves is a log line naming both parties.

There is no audit table on either side of this system. For an operation that hands somebody a working account on the word of the person calling it, that is a real gap, and it is open by decision rather than by oversight. A log rotates, is not queryable by subject, and nothing alerts on it, so somebody investigating a compromised account months later needs the file to still exist.

I would rather write that down than have the series end on a clean note it has not earned.

Where This Leaves It

The first post listed four mechanisms an earlier system carried to keep its tokens self-sufficient: a claim stamped into every token, a blacklist table, a cache of that table, and a second table of spent token hashes.

All four are gone. What replaced them is one row, read on every request, holding four token hashes, a scope, a device key and three timestamps.

Out of that single read come rotation with a replayable previous pair, revocation that takes effect on the next call, three tiers for a half finished sign-in, a device binding that makes a copied refresh token worthless, a branch for the client that lost its key, a per request signature over the whole request, and a table whose size is the number of people signed in right now rather than the number who ever registered.

The JWT still does the job it is good at. It expires on its own, it cannot be edited, and it says which row to read. That was always all it was.


This is the last post in the series. The first is Your JWT Was Never Stateless.