Skip to content

Consume one-time login codes atomically so they cannot be redeemed twice - #2586

Open
watilde wants to merge 1 commit into
OHDSI:masterfrom
watilde:fix/otc-single-use-race
Open

watilde wants to merge 1 commit into
OHDSI:masterfrom
watilde:fix/otc-single-use-race

Conversation

@watilde

@watilde watilde commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Problem

OneTimeCodeService.validateAndConsume reads the code, checks revoked and expiresAt in Java, then issues an unconditional UPDATE ... set revoked = true. Two concurrent GET /user/login/otc?code=... requests for the same code can both read revoked = false and both receive the pre-minted JWT. That breaks the single-use guarantee of one-time codes.

Fix

  • Replace OneTimeCodeRepository.revokeByCode with consumeByCode(code, now), a conditional UPDATE that only matches rows that are still unrevoked and unexpired, and returns the updated row count.
  • validateAndConsume treats the code as redeemed only when this call updated exactly one row. Only then does it load the entity and return the JWT.

Tests

Added OneTimeCodeServiceTest. It covers the successful consume and the case where the conditional update matches no row, where the JWT must not be read. I couldn't build locally (no JDK 21 compiler available), so CI is the first compile and test run for this change.

validateAndConsume read the code, checked revoked/expiresAt in Java, then
issued an unconditional UPDATE. Two concurrent GET /user/login/otc requests
for the same code could both pass the check and both receive the JWT.

Move the revoked and expiry conditions into the UPDATE and treat the code
as redeemed only when this call updated the row.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant