TT Lab
Get started
Learn Learning paths Courses

Sessions and Tokens — From the Browser to the Mesh

Refresh token rotation and reuse detection

Continue in TT Lab

Goal

Stand up a small issuer that stores token families in Redis, and confirm with real HTTP requests that a refresh rotates every time it is used, that when an already used refresh comes in again the whole family is revoked, that the access lives briefly, and that logout cuts the family.

Why it matters

Keeping the access short-lived makes the damage window short even if it leaks, but in exchange the long-lived refresh becomes the new target. For browsers and mobile apps that cannot hide a secret, RFC 9700 requires binding the refresh to a client key or using rotation. The power of rotation comes from reuse detection — if the token leaks and both the attacker and the legitimate client use it, one of the two is sure to present an already used token, and since the server does not know who the attacker is, it cuts that whole family. For this to work, you must not delete the used refresh but leave it as 'used', and that mark must be made atomically. This lab makes you write those rules yourself and prove them for yourself.

Steps

  1. Bring up /root/st/refresh/app.py at 127.0.0.1:8306. GET /health returns 200 and {"ok":true}. You store in Redis as the hashes fam:<id> (user, state, current), rt:<토큰> (user, fam, uses), and at:<토큰> (user, fam) (the placeholder is the token).
  2. Save the response body of POST /login (form user=alice&pw=wonderland) in /root/st/refresh/login.json. GET /api/me with that access returns 200, and a missing or made-up token and a login with a wrong password are 401.
  3. Send POST /token (form grant_type=refresh_token&refresh_token=<값>) with the refresh you got by logging in fresh (the placeholder is the value), and save the response in /root/st/refresh/refresh.json.
  4. Test in the order login, then refresh (RT1), then refresh with the new RT2, then resubmit the old RT1, and write first_status= new_differs= new_status= old_status= into /root/st/refresh/rotate.txt.
  5. Log in as bob/builder, rotate once, submit the old RT1 again, and try the latest RT2 and AT2, then write replay_status= active_after= access_after= family_state= into /root/st/refresh/reuse.txt.
  6. Write the login response's expires_in (1–300 seconds) and the Redis access and refresh TTLs into /root/st/refresh/ttl.txt as expires_in= access_ttl= refresh_ttl=. An expired access must be a 401.
  7. After logging out with POST /revoke (form token=<refresh>), try the same refresh and access again and write revoke_status= refresh_after= access_after= family_state= into /root/st/refresh/revoke.txt.
  8. Test rotation, reuse detection, and family revocation with /root/st/refresh/e2e.sh and leave rotate=ok, reuse_detected=yes, and family_revoked=yes in /root/st/refresh/e2e.out.

Notes

Bring up the token issuer

Bring up /root/st/refresh/app.py at 127.0.0.1:8306. GET /health returns 200 and {"ok":true}. You store in Redis (127.0.0.1:6379) as the hashes fam:<id> (user, state, current), rt:<토큰> (user, fam, uses), and at:<토큰> (user, fam) (the placeholder is the token).

Redis is already running on 6379. Start the server in the background and poll until /health responds. There is no python-multipart, so you unpack the form body yourself with urllib.parse.parse_qs.

Get a pair of tokens by logging in

Save the response body of POST /login (form user=alice&pw=wonderland) as is in /root/st/refresh/login.json. The body must have access_token, refresh_token, token_type (Bearer), and expires_in, and GET /api/me with that access gives 200 and user=alice, while a missing or made-up token is 401 and a login with a wrong password is 401.

State -X POST explicitly with curl and send the form with --data-urlencode. You call the protected API with the Authorization: Bearer header. RFC 9700 forbids the password grant, so the first token is given by /login rather than /token.

Get a new access with a refresh

Send POST /token (form grant_type=refresh_token&refresh_token=<값>) with the refresh you got by logging in fresh (the placeholder is the value), and save the 200 response body in /root/st/refresh/refresh.json. The new access_token must differ from the one at login and must be 200 at GET /api/me.

It is the refresh grant of section 6 of RFC 6749. The body is form-urlencoded. If you reuse the refresh in login.json, it may already be a used token, so log in fresh and get one.

Rotation: the new refresh works and the old one is rejected

Test in the order login, then refresh (RT1), then refresh again with the new refresh (RT2) from the response, then submit the old RT1 once more, and write first_status=200, new_differs=yes, new_status=200, and old_status=<400 또는 401> (the placeholder is 400 or 401) into /root/st/refresh/rotate.txt.

Give a new refresh every time you use a refresh, and do not delete the used refresh but only mark it by raising uses. If you return the same value or keep accepting the old one, it is not rotation.

Reuse detection: revoke the whole family

Log in as bob/builder, rotate once with RT1 (receiving RT2 and AT2), submit the old RT1 again, and then test refresh with RT2 and GET /api/me with AT2, and write replay_status=<400|401>, active_after=<400|401>, access_after=401, and family_state=revoked into /root/st/refresh/reuse.txt (family_state is redis-cli HGET fam:<계열> state, where the placeholder is the family).

The server cannot know who submitted the old RT1. So it kills even that family's current refresh and access. Make /api/me look at the family state too, not only the access key.

Access short, refresh long

Write the login response's expires_in and Redis's TTL at:<access> and TTL rt:<refresh> into /root/st/refresh/ttl.txt as expires_in= access_ttl= refresh_ttl=. expires_in must be 1–300 seconds, access_ttl must be positive and at most expires_in, and refresh_ttl must be greater than access_ttl. An access that has expired in Redis (its key has disappeared) must be a 401 at GET /api/me.

If you express the access lifetime as a Redis key TTL, the expiry happens by itself. The refresh's TTL plays the role of inactivity expiry (RFC 9700 4.14.2).

Logout revokes the family

After logging in, log out with POST /revoke (form token=<refresh>), try the same refresh and access again, and write revoke_status=200, refresh_after=<400|401>, access_after=401, and family_state=revoked into /root/st/refresh/revoke.txt. A /revoke with an unknown token must also be 200.

It has the shape of RFC 7009. When you revoke a refresh, it is a recommendation to invalidate the access that came from the same grant as well, and an unknown token also gets 200. If you delete only the one refresh key, the access keeps working.

Prove rotation, reuse, and revocation at once

Have /root/st/refresh/e2e.sh test in order login, then rotation, then resubmitting the old refresh, then reusing the latest refresh and access, print the three lines rotate=ok, reuse_detected=yes, and family_revoked=yes, and save that output in /root/st/refresh/e2e.out. The grader also reruns e2e.sh to see whether the same result comes out.

Chain the earlier steps together in one script. You have to log in fresh each time so that the same result comes out however many times you run it. family_revoked is yes only when both the latest refresh is rejected and the access is 401.