Designing a JWT auth flow with refresh-token rotation
Most tutorials stop at "issue a JWT and check it on each request." That's fine until a token leaks, a user logs out on one device, or you need to know when a refresh token was reused by an attacker replaying an old request.
The shape of the problem
Hodiya Backend needed:
- Short-lived access tokens (15 min) so a leak has a small blast radius.
- Refresh tokens that rotate on every use — the old one is invalidated the moment a new one is issued.
- Reuse detection: if a refresh token is presented twice, every token in that session's family gets revoked.
Rotation, not just expiry
The naive approach — one long-lived refresh token — means a stolen token works until it expires. Rotation closes that gap: each refresh call swaps the token, so a stolen token becomes useless the next time the legitimate client refreshes. If the attacker's stolen copy is used *after* the legitimate one, the reuse gets caught and the whole chain is killed.
def refresh(old_token: str) -> TokenPair:
record = tokens.get(old_token)
if record is None or record.revoked:
revoke_family(record.family_id)
raise AuthError("token reuse detected")revoke(old_token) return issue_pair(user_id=record.user_id, family_id=record.family_id) ```
Documenting it before the client existed
I wrote the OpenAPI spec before building the Flutter client that would consume it. It felt slow at first, but it meant the mobile side and the API could be built in parallel against a contract instead of guessing — and PayHere's payment callbacks, which needed their own auth path, were easy to slot in without breaking the main flow.
The full write-up of the payment gateway integration is a story for another post.