Part of the Refresh Token Design series.

The Gap Device Binding Left

Binding the refresh token to a device means a copied refresh token is worth nothing on its own. Good.

It also means the refresh token is now the better protected of the two credentials, and the access token, which is sent on every single request, is still a bearer secret. Anybody who captures one has five minutes of full access and needs nothing else.

Five minutes is a long time. It is enough to read a customer list, change an email address, or start a payment.

What Was Refused, and What Was Not

The design document for this system spent a section refusing per request signatures, and this post reverses that. The distinction is worth being precise about, because the original refusal was right about the thing it was refusing.

What was refused was dropping the per request session read and letting a signature stand in for it. That is a bad trade. The session read is what makes revocation immediate, and a signature proves possession of a key, not that the session behind it is still alive.

The read stays. What the refusal never weighed was what a signature buys while the read stays, which is closing the gap above. The two were treated as alternatives when they are layers.

The Canonical String

Four fields, newline separated, signed with the same device key that signs the refresh proof.

<timestamp millis>\n<METHOD>\n<path and query as sent>\n<hex sha-256 of body>

The shape is borrowed from RFC 9421 and AWS SigV4 without being either. It travels in two headers, one for the timestamp and one for the signature, and the timestamp is a header rather than a field inside a signed envelope so the window can be checked before an elliptic curve verification is spent on a request that is an hour old.

Four Things That Are Load Bearing

The body is hashed as it arrived. Not after parsing. Re-serialising parsed JSON produces different bytes for the same document, whitespace and key order being the obvious two, and every signature would fail with nothing to look at. Hash the raw bytes off the wire.

The method is in the string. Without it, a signature for a GET replays as a DELETE on the same path.

The path is in the string, as sent. Not the matched route template. The client cannot know that /users/17 matched /users/{id}, and two ids under one pattern must never share a signature.

The query is in there too. Otherwise ?page=1 is free to become ?page=2 under a signature that still verifies, and ?limit=10 is free to become ?limit=100000.

Three of those four are omissions that produce a scheme which verifies correctly and protects nothing.

Reading the Body Before Anyone Else

A servlet request body is a stream you get once. The signature has to be checked before the body reaches a controller, so the filter has to read it, which means the controller gets nothing unless you hand the bytes back.

Spring ships ContentCachingRequestWrapper and it does not fit. It records what was read as it is read, which is the right shape for logging a request after the fact and the wrong one here: at the moment you need the bytes, it has cached nothing.

What is needed is a wrapper that holds the bytes up front and hands out a fresh stream to whoever asks next.

class CachedBodyRequest(request: HttpServletRequest, val body: ByteArray) :
    HttpServletRequestWrapper(request) {

    override fun getInputStream(): ServletInputStream {
        val source = ByteArrayInputStream(body)
        return object : ServletInputStream() {
            override fun read(): Int = source.read()
            override fun isFinished(): Boolean = source.available() == 0
            override fun isReady(): Boolean = true
            override fun setReadListener(listener: ReadListener) = Unit
        }
    }
}

Bound the buffer. Buffering is the one cost an attacker can ask for by sending a large body, so a declared length above the cap is refused before anything is read, and the read itself is capped in case the length lied.

The Replay Guard

A signature proves the request was composed by the device. It does not stop somebody who captured that exact request from sending it again.

So each verified signature is recorded in a set with a short expiry, and a second appearance is refused.

val claimed = redis.opsForValue()
    .setIfAbsent("SIG:${sha256(signature)}", "1", SEEN)

Two properties of that are deliberate.

The set is checked only after the signature verifies. Otherwise anybody could fill it with values they never had to sign, and evict or collide against real entries.

The entry lives longer than the accepted window. Fifteen seconds of acceptance against twenty seconds of memory. If the two were equal, a request arriving at the very last moment of its window would find its earlier copy already forgotten, and the last instant of every window would be unguarded.

Nothing honest ever replays a signature byte for byte, because a client retrying a lost request stamps a fresh timestamp and signs again.

When the Cache Is Down

The replay guard needs shared state, and shared state fails.

The choice is to refuse every signed request while the cache is unreachable, or to skip the replay check and still verify the signature.

Skip it. Refusing every request in the fleet during a cache outage is a much larger failure than leaving a fifteen second replay window open to somebody who is already inside the connection. The signature check itself needs nothing shared, so the expensive half of the protection survives the outage intact.

This is a general rule worth having an opinion on before the incident rather than during it: a degraded check that still refuses forgeries beats a check that takes the whole service down with its dependency.

Two Refusals, and They Are Not the Same

An invalid signature and a replayed signature must be different codes, because the correct client behaviour is opposite.

Invalid changes nothing on the server. Not the token, not the session, not the scope. Deleting the access token to force a refresh was considered and refused: somebody holding a captured token could then send rubbish in a loop and force the real person to refresh every few seconds for ever. Refusing the one request costs the attacker everything and the real client nothing, since their own token was never the problem. The client refreshes once and retries, with a circuit breaker so a fresh pair that still fails goes to the login screen rather than looping.

Replayed must never be refreshed on. The refusal means the first copy was processed. Resending is a second write, not a retry. There is no generic client behaviour that is safe here, so the page decides: a read asks again with a fresh signature, a write tells the person and offers to resubmit.

Getting this wrong is how a retry layer quietly creates duplicate payments.

Who Is Not Asked

Not every caller has a browser key. Service to service credentials and API keys have their own mechanisms and no WebCrypto behind them.

The exemption is read off the verified principal and never off a claim in the token. The only way to be recognised as one of those callers is to have already passed that credential type’s own verifier, which means the exemption cannot be claimed by anybody who is not already authenticated as that kind of caller.

An exemption keyed on something in the request is an exemption an attacker can assert.

Raw Signatures and DER

One practical note that costs an afternoon. WebCrypto’s ECDSA produces a raw r||s signature, 64 bytes for P-256. The JDK verifier wants DER.

One side has to convert and it is cheaper on the server than in every browser.

private fun der(raw: ByteArray): ByteArray? {
    if (raw.size != 64) return null
    val body = integer(raw.copyOfRange(0, 32)) + integer(raw.copyOfRange(32, 64))
    return byteArrayOf(0x30, body.size.toByte()) + body
}

private fun integer(half: ByteArray): ByteArray {
    var trimmed = half.dropWhile { it == 0.toByte() }.toByteArray()
    if (trimmed.isEmpty()) trimmed = byteArrayOf(0)
    if (trimmed[0].toInt() and 0x80 != 0) trimmed = byteArrayOf(0) + trimmed
    return byteArrayOf(0x02, trimmed.size.toByte()) + trimmed
}

The leading zero matters. DER integers are signed, so a half whose top bit is set needs a zero byte pushed in front of it or the value reads as negative and roughly half your signatures fail for no visible reason.


Next: The Day Two Security Features Cancelled Each Other Out