SecureLaunch
← All articles

2026-07-22 · 7 min

FastAPI refresh-token rotation with reuse detection (the part tutorials skip)

Most JWT tutorials stop at “here is how to issue a token”. Here is how to rotate refresh tokens, detect theft, and revoke a whole token family — with the exact FastAPI patterns.

Most FastAPI auth tutorials stop at "here is how to sign a JWT" and skip the parts that actually bite you in production. This is a write-up of the decisions behind a hardened auth layer — the same design shipped in SecureLaunch. The reasoning stands on its own; take what is useful.

1. Pick PyJWT, and lock the algorithm

python-jose has a bumpy CVE history and irregular maintenance. PyJWT is smaller, actively maintained, and does everything you need. Two things are non-negotiable when you decode:

2. Refresh tokens should be opaque, not JWTs

Here is the core idea most tutorials miss: a stateless JWT cannot be un-issued. If your refresh token is a JWT, you cannot revoke it — you can only wait for it to expire. So refresh tokens should be opaque random strings, stored hashed (SHA-256), with a real row in the database.

Because the refresh token lives in the database, rotation and revocation become actual operations instead of wishful thinking.

3. Rotation + reuse detection = theft detection

On every refresh, the old token is rotated out and a new one issued. The important part: if an already-used refresh token shows up again, revoke the entire token family. A reused refresh token is the strongest signal you have that a token was stolen and replayed — treat it as a breach, not an error.

Concretely: each token stores a family id. Presenting a rotated-out token triggers a cascade that invalidates every sibling. The legitimate user is logged out once; the attacker's copy is dead.

4. Anti-enumeration, honestly

Register and forgot-password should return a constant 202; login returns a single 401 with a dummy hash check to equalise timing. Be honest about the trade-off, though: the moment you add per-account lockout, a 429 on a locked (existing) account versus a 401 on an unknown one becomes an existence oracle. There is no free lunch — you trade it against IP-based rate-limiting, and you document it.

5. Passwords: argon2id, not bcrypt-via-passlib

passlib is effectively unmaintained, and bcrypt silently truncates passwords at 72 bytes — so two different long passwords can collide. argon2id (via argon2-cffi) is the current OWASP recommendation. Wrap it so rehash-on-verify is automatic when parameters change, and enforce a 12–128 character policy with no truncation.

The uncomfortable takeaway

None of this is exotic — it is just the 20% of auth that tutorials leave as "an exercise for the reader", and it is exactly where early-stage SaaS get owned. You can write it yourself in a weekend; the value is in getting every one of these details right and having them verified. That verification — a written adversarial review plus an OWASP ASVS L1 audit — is what SecureLaunch actually sells.