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:
- Pin the algorithm to a closed list — pass
algorithms=["HS256"]and never trust thealgfield from the incoming header. That header is the classic alg-confusion foot-gun. - Require the claims you depend on, and verify a custom
typeclaim so an access token can never be replayed where a refresh token is expected.
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.
- Access tokens: short-lived JWTs (15 min).
- Refresh tokens: opaque, 256-bit, stored as a SHA-256 hash, 30-day lifetime.
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.