TT Lab
Get started
Learn Learning paths Courses

Sessions and Tokens — From the Browser to the Mesh

How the server notices a stolen refresh token

Continue in TT Lab

In one line

You keep the access token short-lived and swap it out with a refresh token. In exchange, a refresh is changed once it is used (rotation), and if a refresh that was already used comes in again, every token that continued from that login is cut off (reuse detection). It is a design that stops the theft even when the server does not know who the thief is.

Why this was needed

If you keep the access token short-lived, the damage window is short even if it leaks. RFC 9700 4.14 notes that the refresh token is exactly what makes this possible — because you can keep issuing short-lived, narrowly scoped access tokens without making the user log in again.

The problem is that the risk moves to the refresh side. The same section warns that the refresh represents the whole of the authorization that client received and that, if stolen and replayed, an attacker can mint access tokens at will. A confidential client that keeps a secret on the server authenticates the client when it uses a refresh, so it cannot be used with the token alone (RFC 6749 10.4). But a public client such as a browser SPA or a mobile app has no secret to hide.

So RFC 9700 — a document long known as the draft draft-ietf-oauth-security-topics and published in January 2025 as BCP 240 — sets out in section 2.2.2 that the refresh of a public client must be sender-constrained or use rotation (MUST). The former is a method of binding the token to the client's key with DPoP or mTLS, and this lab builds the latter yourself.

How it works

Rotation means giving a new refresh as well every time you receive a new access with a refresh. The RFC 9700 text writes "The previous refresh token is invalidated, but information about the relationship is retained". That is why you leave only a 'used' mark instead of deleting the old token. If you delete it, you cannot tell whether an old token that comes back in is a garbage value seen for the first time or an already used token, and then the basis for detecting reuse disappears.

Reuse detection works like this. If the token leaks and both the attacker and the legitimate client use it, whichever used it first rotates it, so the later one is sure to present an invalidated token. The server does not know who the attacker is — the attacker may have used it first. So RFC 9700 says to revoke the active refresh in this case. The legitimate user also has to log in again, but the attack stops there. The chain of tokens that continued from a single login is called a family, and revocation is done per family.

POST /login         fam:F state=active   rt1(uses=0)  at1
POST /token rt1     rt1 uses=1 → rt2, at2               정상 회전
POST /token rt1     uses=2 → 재사용! fam:F state=revoked → 400 invalid_grant
POST /token rt2     계열이 죽었음 → 400 invalid_grant
GET  /api/me at2    계열이 죽었음 → 401 invalid_token

The response codes follow the standards too. To an invalid, expired, or revoked refresh, the token endpoint answers 400 invalid_grant (RFC 6749 5.2), and a protected API gives an invalid access 401 and WWW-Authenticate: Bearer error="invalid_token" (RFC 6750 3.1). You build logout in the shape of the RFC 7009 revocation endpoint. When you revoke a refresh, it is a recommendation (SHOULD) to invalidate the access that came from the same grant too, and a token it does not know also gets 200 — because the purpose of making that token unusable has already been achieved.

Finally, the 'used' mark must be made atomically. If you do read, compare, and write separately, two requests that arrive at the same time with the same token both see "not used yet" and both receive a new token. Redis's HINCRBY does the increment and the returning of the result in one command, so there is only ever one request that sees 1.

What it looks like in the field

Once you turn rotation on, reports come in that "sometimes I get logged out on my own". If two tabs send the same refresh twice at the same time to renew an expired access, the second looks like a reuse and the whole family is gone. It is not an attack but a race condition. The place to fix is not the detection but the client — gather the renewal requests into one place and have it send only once. It is for this reason that products such as Keycloak let you configure turning rotation on and a 'number of reuses to allow' separately, but the more you raise the count, the looser the detection gets.

Another is a long-neglected refresh. RFC 9700 says to expire a refresh if the client has not used it for a while (SHOULD), and that it may be revoked automatically (MAY) on a security event such as logout or a password change. In this lab, the Redis key TTL handles the inactivity expiry.

What you will do in the next lab

You stand up a small issuer that stores families in Redis. You get a pair of tokens by logging in, get a new access with a refresh, and see that the old refresh is rejected. Then you resubmit a stolen old refresh and confirm that the legitimate client's latest refresh and access die along with it, and after going through a short access lifetime and logout revocation, you prove the whole flow with one script.