From 793e410554b461522fbe24014dfd3baa915da2bb Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 16:13:22 +0200 Subject: [PATCH] fix: floor password_changed_at when comparing against JWT iat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWT `iat` has 1-second resolution; `password_changed_at` is stored with sub-second precision. The previous comparison rejected tokens whose iat fell in the same wall-clock second as a password change — e.g. a token issued by an immediate re-login after a password reset, or by any script-driven flow that resets and logs in in quick succession. Floor the stored timestamp to whole seconds before comparing. Caught while wiring up the local E2E suite: the seeder needed a "set password_changed_at 10 s in the past" hack to avoid this race; with the fix in place that hack is gone and the suite is naturally deterministic. --- backend/src/middleware/auth.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 4522d7c4..214485f1 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -91,10 +91,16 @@ async function adminAuth(req, res, next) { return res.status(401).json({ error: 'Invalid token' }); } - // Check if password was changed after token was issued + // Check if password was changed after token was issued. JWT `iat` has + // 1-second resolution; `password_changed_at` is sub-second. Floor the + // comparison so a token issued in the *same* second as the password + // change isn't incorrectly rejected — that race used to bite anyone + // logging in immediately after a password reset/change. if (admin.password_changed_at) { - const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000; - if (decoded.iat < passwordChangedTime) { + const passwordChangedSeconds = Math.floor( + new Date(admin.password_changed_at).getTime() / 1000 + ); + if (decoded.iat < passwordChangedSeconds) { logger.warn('Token used after password change', { userId: decoded.id }); return res.status(401).json({ error: 'Token invalid due to password change',