Refresh tokens are validated but never invalidated after use. A leaked refresh token stays valid for its full 30-day TTL and can be replayed indefinitely, defeating the point of short-lived access tokens.
const record = await store.find(refreshToken); if (!record || record.revoked) throw new AuthError("invalid_grant"); - // token reused as-is on every refresh - return signAccessToken(record.userId); + // rotate: revoke old, mint a fresh refresh token + await store.revoke(record.id); + const next = await store.issue(record.userId); + return { access: signAccessToken(record.userId), refresh: next };
Suggested fix: Rotate the refresh token on every exchange and revoke the prior token; reject reuse of a revoked token (replay detection).
Expiry is compared against Date.now()
(milliseconds) while the JWT exp claim
is in seconds. Every token reads as expired ~1000× too early, so sessions
drop almost immediately in production.
- if (claims.exp < Date.now()) throw new TokenExpiredError(); + if (claims.exp < Math.floor(Date.now() / 1000)) throw new TokenExpiredError();
Suggested fix: Convert to seconds for the comparison, or use a single time helper shared by signing and verification.
The middleware swallows verification errors and falls through to an anonymous session on any failure, including a tampered signature. A malformed token should be a 401, not a silent downgrade.
Suggested fix: Distinguish "no token" (continue anonymous) from "invalid token" (respond 401) and log the verification error.
Access-token TTL is hard-coded to "15m"
as a string and parsed ad hoc in two places. The two parsers disagree on
whitespace handling, which is fragile.
Suggested fix: Store the TTL as a number of seconds in config and parse once at load time.
Two removed tests covered the old long-lived-token path; their rotation replacements are not yet added, so refresh rotation is currently untested.
Suggested fix: Add a test asserting that a reused refresh token is rejected after rotation.
Imports are no longer ordered after the refactor and there is one unused
crypto import left over.
Suggested fix: Run the linter's auto-fix; drop the unused import.