Changing your password should end the sessions that were open before it. Very often that is exactly why somebody changes it — the old one just leaked.
With stateless JWT there is no session list to clear, so the usual trick is to
compare two timestamps: when the token was issued (iat), and when the password
was last changed. A token from before the change is refused.
I wrote it. The test failed. Sometimes.
The failure
The test signed in, changed the password, signed in again, and checked that the old token was refused while the new one worked. Most runs passed. Some runs refused the new token too.
Both tokens were minted within the same second as the password change. That was the whole clue, and it took me a while to see it.
What was actually happening
PyJWT converts a datetime given for a registered claim through
calendar.timegm(), which keeps whole seconds and drops the rest. So:
password_changed_at— a Pythondatetime, microseconds intactiat— truncated to the second on its way into the token
One side of the comparison had sub-second precision and the other did not. A
token minted at 12:00:00.400, a fraction of a second after a change at
12:00:00.200, came back out of the token as 12:00:00.000 — and compared as
earlier. The guard refused a token it had just issued.
In production nobody signs in within the same second as changing their password, so this would have shown up as a rare, unreproducible complaint. In tests both things happen in the same millisecond, every run.
The fix
RFC 7519 says a NumericDate is allowed a fractional part. PyJWT keeps it if you pass a float instead of a datetime:
to_encode["iat"] = datetime.now(UTC).timestamp()
One line, and both sides of the comparison have the same precision.
What I took from it
The bug was not in the comparison, and not in the logic. It was in an implicit
conversion I did not know was happening, inside a library doing something
reasonable — nobody needs a session to expire mid-second, so truncating exp
is fine.
It stopped being fine the moment I used the same mechanism for something else.
I have written that reasoning into a comment above the line, because the line itself looks like an arbitrary choice between two equivalent ways to write a timestamp. It is not, and the next person to tidy it up deserves to know why.