Part of the Refresh Token Design series.

The Actual Defect

The previous post ended on a reframing worth repeating. Reuse detection was never really about token ordering. It was trying to answer “is this a different person” by looking at “has this token been seen”, which is a bad proxy, and the false logouts are the evidence.

The real defect is that a refresh token is a bearer secret. Whoever holds the bytes is the session. Copy them out of storage, a log, a crash report, a proxy, and you are indistinguishable from the person they belong to.

So stop accepting bytes on their own.

A Key the Browser Cannot Hand Over

At login the client generates an ECDSA keypair through WebCrypto and marks the private half non-extractable.

const pair = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  false,                       // the private half can never be exported
  ['sign', 'verify'],
);

Two details about that call.

The false applies to the private key only. WebCrypto always marks the public half extractable regardless, which is what lets you send it, and is worth knowing before you spend an afternoon debugging an export that you expected to fail.

And what gets stored is the CryptoKey handle, in IndexedDB, not key material anywhere.

const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').put({ id: 'device', pair });

IndexedDB stores the handle using the structured clone algorithm, so the bytes stay inside the browser’s crypto implementation. Script on the page can ask for a signature and cannot read the key. Neither can anything that gets hold of the database file. That is the entire reason the key is not simply kept in localStorage, where it would be one XSS away from being copied alongside the token it protects.

The public half goes to the server in the login body and lands in a column on the session row.

The Proof

Every refresh carries a short ES256 signature over the session id and a hash of the refresh token being presented.

const claims = {
  sid: sessionId,
  rth: await sha256Hex(refreshToken),
  iat: serverNow(),
};

The hash rather than the token, because a proof travels in a header, and headers end up in proxy logs, error reports and browser dev tools. Putting the refresh token itself in one would undo the exercise.

The server verifies it against the key on the row.

private fun provesDevice(session: Session, presentedHash: String, proof: String?): Boolean {
    val key = session.devicePublicKey ?: return true
    return deviceProofVerifier.verify(proof, key, session.id, presentedHash)
}

Hold that line. We will come back to it, and it is the most expensive line in this post.

What a stolen refresh token is worth now: nothing on its own. The thief has the bytes and cannot produce a signature over them, because the key that would do it never left the browser it was generated in, and cannot be made to.

Three Moments, and No Fourth

The server accepts a device public key at exactly three points.

Session creation, because that is a fresh login with a password and whatever else the account requires.

A proved second factor, which is the rehydration path. A browser that lost its key proves a factor and is rebound to a new one.

An internal handover, where one service mints a session on behalf of another and forwards the key off the session it already holds.

There is no fourth, and the one that gets proposed is accepting a key on a refresh. It sounds harmless, since the caller is presenting a valid refresh token. It is the whole mechanism handed back: whoever stole that token registers their own key and becomes the device.

The second moment is safe for the reason the fourth is not. A second factor is the one thing a stolen refresh token cannot supply.

The Clock Is the Server’s

A proof carries a timestamp and the server checks it against a window. The obvious implementation reads Date.now() and is wrong.

Consumer machines are not on NTP. A laptop a few minutes out fails every proof it will ever make, and the symptom is not an error anybody can trace. It is a person saying the site keeps asking them for a code every five minutes.

Widening the window does not fix it either. A machine four minutes out fails a three minute window exactly as completely.

So the server answers its own time on login and on every refresh, the client stores the difference once, and everything that needs a timestamp asks for the corrected value.

export function serverNow(): number {
  const skew = Number(localStorage.getItem('clock.skew') ?? 0);
  return Date.now() + skew;
}

One consequence worth stating: an out of window rejection is the single proof failure worth logging at warning level. The others are ordinary. This one means somebody’s machine will never work until the skew is learned, and you want to see it.

Losing the Key Is Normal

This is the part that decides whether the design survives contact with real browsers.

Safari deletes script-writable storage after seven days of Safari use without a visit to the site. Every browser evicts under disk pressure. Private windows clear on close. People clear browsing data.

So the key going missing is not an exception path, it is a Tuesday. Any design that treats it as an anomaly will sign out a meaningful share of its users every week.

And note precisely what arrives when it happens, because the obvious assumption is wrong. The client does not send no header. It finds no key, generates a fresh pair on the spot, and signs with that. What reaches the server is a perfectly well formed proof it cannot verify. The log line is “did not verify” rather than “missing”.

A missing header is the narrower case: a browser with no WebCrypto at all, which means any insecure origin.

Both have to lead somewhere other than a dead end, and where they lead is the next post.

The One Line That Made It Optional

Back to this.

val key = session.devicePublicKey ?: return true

That line shipped for a sensible reason. The column was added to a table with live rows in it, and rows written before the column existed carry null, so the check has to tolerate them or the deploy is a fleet wide logout.

Read it as an attacker would. A session with no key passes every proof, for ever, without presenting one. It is not weakly bound. It is outside the mechanism entirely, and it can never be narrowed for failing a check it is incapable of failing.

Which means the whole scheme is only as strong as the guarantee that no session can be created without a key. If the key is an optional field on the login request, anybody can ask for a session outside the mechanism just by leaving it out.

So the key is mandatory. A login with no key is a 400 rather than a session. A key that is present but unparseable is refused explicitly rather than logged and dropped, because a client that believes it has bound a key and has not is in the worst state available. The column stays nullable only because old rows exist, and those drain within a refresh lifetime.

The general shape is worth carrying elsewhere: a security check with a null tolerant fallback is a security check with a documented bypass, and the bypass is whatever produces the null.


Next: Branch the Session Instead of Narrowing It