Part of the Refresh Token Design series.
Signing In Is Not a Boolean
Most auth code treats the session as a switch. Either somebody is in or they are not.
Real sign-in has states in between, and each one needs a session that exists and reaches almost nothing.
Somebody types a correct password and owes a second factor. They are not signed in, but they have to be able to call the route that lists their factors, the route that sends a code, and the route that verifies one. Those routes need to know who is answering, which means a session, which means a token.
Somebody’s session has sat untouched for eight hours on a machine in a back office. It should not simply continue, and it should not simply die either.
Somebody’s browser lost the key its refresh token is bound to, which is a normal Tuesday once storage eviction is in the picture. They can prove a factor. They cannot prove the device.
Three different states, all of them “signed in enough for some things”.
The Tier
The answer is one column, read on every request, from lowest to highest.
enum class TokenScope { UNVERIFIED, MFA_PENDING, FULL }
UNVERIFIED: this caller could not prove the device the session is bound to.MFA_PENDING: the password is proved, the second factor is not.FULL: everything is proved.
Each route declares the lowest tier that may reach it. The gate compares, and anything short of that answers 401 with a code the client can branch on, rather than 403, because the caller is not forbidden from the route, they are not finished yet.
Read the Row, Not the Claim
The scope is stamped into the token as a claim and nothing reads it there.
It is tempting, because the claim is already parsed and the row costs a read. It is also wrong, and the case that breaks it is specific.
Rotation replays a stored pair. That pair was minted at some earlier moment, possibly before the session was narrowed for idling. So the claim on a replayed token says FULL while the row says MFA_PENDING, and the row is right. A gate reading the claim would serve a request the session is no longer entitled to, and would do it for the full lifetime of that token.
The claim is a cache of what the row said at minting time. Anything that decides access reads the row.
Why the Lowest Tier Is Not the Middle One
This is the part worth the post, because the obvious move is to have two tiers and reuse the middle one for both cases. Both are “prove a factor and we will let you through”. I built it that way first.
The difference is enrolment.
MFA_PENDING has to reach the enrolment routes. Somebody whose account requires a second factor and who has never set one up cannot verify a factor they do not have, so if that tier could not enrol they would be locked out by the requirement itself.
On the device rehydration path, that same allowance is the attack.
A session lands at UNVERIFIED because a refresh arrived that could not prove the device. The honest case is a browser that lost its key. The other case is somebody holding a stolen refresh token. If that tier reached enrolment, the thief would enrol their own authenticator, verify it, and have a fresh device key written for them against the session. They would walk in through the recovery door.
So the lowest tier proves a factor that already exists, and that is all it does. Its whole list is: read which methods the account holds, send a code if email is the factor, verify one, read who the caller is, and sign out.
val UNVERIFIED_SCOPED_ENDPOINTS = arrayOf(
ROUTE_AUTH_MFA_VERIFY,
ROUTE_AUTH_MFA_OTP_SEND,
ROUTE_AUTH_MFA_METHODS,
ROUTE_ME,
ROUTE_AUTH_LOGOUT,
)
It is a strict subset of the middle tier, and the single element that differs is the one that matters.
Sign Out Belongs at the Bottom
That last entry was added later than the rest, and it is the only thing below the top tier that acts rather than reads.
Without it the challenge screen is a room with one door. Somebody who does not want to be there, because they typed the wrong address or sat down at a machine where the last person left a half finished sign-in, can either complete a challenge for an account that is not theirs or clear their browser storage. The client drops its own tokens either way and the row goes on existing at the server until it expires.
It is not a capability worth withholding at this tier. The caller already holds the session, the act reaches nothing but itself, and the grant is still checked before the tier is. What it costs is that somebody holding a stolen mid-challenge token could end a real person’s half finished sign-in, which is a nuisance rather than a compromise, because that session reaches nothing and the answer is to sign in again.
Signing every other session out stays at the top tier, deliberately, because it acts on sessions other than the one asking.
Grant First, Tier Second
Two checks run per request and the order is fixed: does this person hold this capability at all, and only then, is their session far enough along to use it.
The order matters for a reason that is easy to miss until a test catches it. If the tier were checked first, a route somebody does not hold would answer “finish signing in” instead of “you do not have this”, which tells an unfinished session which capabilities exist behind the challenge.
It also means a test that wants to prove a tier refusal has to name a route the subject genuinely holds. Point it at something they were never granted and the answer is 403 from the grant check, the tier is never consulted, and the test proves nothing while passing.
Make the Lists Disagree Loudly
The tier for each route lives in a catalog table, and the two lower tiers also exist as constant arrays in code, because the parts of the system that run before a handler is matched need them without a database read.
Two sources of truth for one fact drift, and the direction they drift in is the dangerous one: a route quietly sliding down into the rehydration tier, where a stolen refresh token can reach it.
So the check runs at startup and refuses to boot when they disagree, in both directions.
fun validateTiers() {
val declared = catalog.routesAtOrBelow(TokenScope.MFA_PENDING)
val listed = (MFA_SCOPED_ENDPOINTS + UNVERIFIED_SCOPED_ENDPOINTS).toSet()
val missing = declared - listed
val extra = listed - declared
require(missing.isEmpty() && extra.isEmpty()) {
"Tier lists disagree with the catalog. Only in catalog: $missing. Only in code: $extra."
}
}
A service that will not start is a loud failure at the one moment somebody is watching. A route that silently became reachable one tier lower is a quiet one nobody is watching at all.
If you are wiring the filter and gate side of this in Spring Boot, the mechanics of the chain are in Security Setup in Spring Boot. This post is about what the tiers mean rather than where the check is installed.