fix(auth): accept a TOTP code only once (#849) - #851
Merged
alex-dembele merged 2 commits intoOct 2, 2026
Merged
alex-dembele merged 2 commits into
alex-dembele merged 2 commits into
Conversation
VerifyTOTP answers yes or no, so a caller cannot tell a fresh code from one it already accepted. MatchTOTPStep returns the matching step within the same ±1 tolerance, comparing in constant time, so the caller can refuse a replay. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
A code could be used again for about 90 s after it was typed: to open a second session, confirm a second sensitive action, or turn MFA off. Each secret now records the step of the last accepted code (mfa_secrets.last_totp_step, added by AutoMigrate and migration 0066), and a code is accepted only for a later step, in one conditional UPDATE so concurrent requests cannot both win. Login, enrolment, the MFA disable and the step-up gate all go through it; a replay is refused exactly like a wrong code. Saves of the secret no longer write the column, so a stale struct cannot lower the mark. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
This was referenced Sep 30, 2026
Base automatically changed from
754-securityauth-disabling-mfa-does-not-re-verify-the-password
to
master
October 1, 2026 12:21
alex-dembele
deleted the
849-securityauth-a-totp-code-can-be-replayed-within-its-validity-window
branch
October 2, 2026 12:36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #849
A TOTP code used to be accepted as many times as it was typed within its ±30 s window (about 90 s). Now each code is accepted once, everywhere a code is checked: login challenge, enrolment verification, turning MFA off (SSO accounts, #754), and the step-up gate (
authmfa.Gate).How it works
otp.MatchTOTPStep(secret, code, now)returns the time step the code belongs to (±1 step, constant-time comparison).mfa_secrets.last_totp_step(bigint, nullable) stores the step of the last accepted code. It's added by AutoMigrate and by migration0066. Thedownremoves nothing, on purpose: dropping the column would reopen the replay, and the CLAUDE.md rule on dropping columns applies.GormMFARepository.ConsumeTOTPStepdoes one conditionalUPDATE … WHERE user_id = ? AND tenant_id = ? AND (last_totp_step IS NULL OR last_totp_step < ?). One row changed means accepted; zero rows means a replay. Postgres serialises the row, so two concurrent requests can't both win. The sameUPDATEsetslast_used_at, which was previously written but never read.UpdateMFASecretnow leaves outlast_totp_step. Without that, aSaveof a stale struct after a challenge would lower the mark and reopen the replay.Side effect: the code typed to finish enrolment can't be reused to sign in within the same 30 s; the next code is needed. That's the behaviour RFC 6238 §5.2 recommends.
Verification
Migration 0066 on a fresh PG 16 database:
upapplied twice (idempotent, NOTICE on the second run),downran without error, and the column isbigint.Live run (branch server on PostgreSQL 16 + Redis 7, fresh database):
Not done
MarkBackupCodeAsUsed), and criterion 4 asks for no change.🤖 Generated with Claude Code