{"record":{"id":"ef49a7a8a9da21ab","repo":"Freika/dawarich","slug":"token-already-consumed","errorCode":null,"errorMessage":"token already consumed","messagePattern":"token already consumed","errorType":"http","errorClass":"Auth::VerifyOtpChallengeToken::TokenReplayed","httpStatus":401,"severity":"warning","filePath":"app/services/auth/verify_otp_challenge_token.rb","lineNumber":26,"sourceCode":"    CONSUMED_KEY_PREFIX = 'otp_challenge:consumed:'\n\n    def initialize(token)\n      @token = token\n    end\n\n    def call\n      raise InvalidToken, 'blank token' if @token.blank?\n\n      decoded, = JWT.decode(@token, Auth::InternalTokenSecret.call, true, algorithm: 'HS256')\n      raise InvalidToken, 'wrong purpose' unless decoded['purpose'] == 'otp_challenge'\n      raise InvalidToken, 'missing jti' if decoded['jti'].blank?\n\n      if decoded['iat'].present? &&\n         (Time.now.to_i - decoded['iat'].to_i) > Auth::IssueOtpChallengeToken::TTL.to_i\n        raise InvalidToken, 'token too old'\n      end\n\n      raise TokenReplayed, 'token already consumed' if token_consumed?(decoded['jti'])\n\n      user = User.find_by(id: decoded['user_id'])\n      raise InvalidToken, 'user not found' unless user\n\n      @jti = decoded['jti']\n      user\n    rescue JWT::DecodeError => e\n      raise InvalidToken, e.message\n    end\n\n    def mark_consumed!\n      return false if @jti.blank?\n\n      Rails.cache.write(\n        \"#{CONSUMED_KEY_PREFIX}#{@jti}\",\n        true,\n        expires_in: Auth::IssueOtpChallengeToken::TTL,\n        unless_exist: true","sourceCodeStart":8,"sourceCodeEnd":44,"githubUrl":"https://github.com/Freika/dawarich/blob/97fad417c5a11b0eb11157890635e015723a2e97/app/services/auth/verify_otp_challenge_token.rb#L8-L44","documentation":"Raised as Auth::VerifyOtpChallengeToken::TokenReplayed (subclass of InvalidToken) when the token's jti already has a 'otp_challenge:consumed:<jti>' entry in Rails.cache, i.e. mark_consumed! was called for this token before. This is deliberate one-time-use semantics: each OTP challenge token may complete verification+consumption exactly once.","triggerScenarios":"User double-clicks submit and the verify endpoint runs twice with the same token; a retry (network timeout on first submit) replays the token; the client caches and resends the challenge token on page refresh. Note the race window: verify checks consumption, caller then calls mark_consumed! — two concurrent requests can both pass token_consumed? unless consumption is atomic.","commonSituations":"Idempotency-unaware AJAX retries, browser back-button resubmission of the OTP form, mobile offline queue replaying the request, or a bug where mark_consumed! is invoked even when downstream signup/login failed so the user cannot retry with the same code.","solutions":["On the client, disable the submit button and make the verify request idempotent per challenge (send once, treat network errors with a fresh challenge).","On the server, rescue TokenReplayed separately from InvalidToken and respond 409/'already used' so the UI can prompt for a new code.","Only call mark_consumed! after the consuming action succeeds, or make consumption atomic (Rails.cache.fetch(add: true) style SETNX) to close the check-then-consume race.","Ensure cache TTL for consumed keys exceeds the challenge TTL so entries do not evaporate while tokens are still temporally valid."],"exampleFix":"# before: rescue everything as generic invalid\nrescue Auth::VerifyOtpChallengeToken::InvalidToken => e\n  render json: { error: e.message }, status: :unauthorized\n\n# after: distinguish replay\nrescue Auth::VerifyOtpChallengeToken::TokenReplayed\n  render json: { error: 'This code was already used. Request a new one.' }, status: :conflict\nrescue Auth::VerifyOtpChallengeToken::InvalidToken => e\n  render json: { error: e.message }, status: :unauthorized","handlingStrategy":"try-catch","validationCode":"# Close the race at consumption time with an atomic add\nkey = \"#{Auth::VerifyOtpChallengeToken::CONSUMED_KEY_PREFIX}#{jti}\"\nfirst_use = Rails.cache.redis.set(key, '1', nx: true, ex: ttl) # only first writer wins","typeGuard":null,"tryCatchPattern":"begin\n  verifier = Auth::VerifyOtpChallengeToken.new(token)\n  user = verifier.call\n  verifier.mark_consumed!\nrescue Auth::VerifyOtpChallengeToken::TokenReplayed\n  render json: { error: 'Code already used - request a new one' }, status: :conflict\nrescue Auth::VerifyOtpChallengeToken::InvalidToken => e\n  render json: { error: e.message }, status: :unauthorized\nend","preventionTips":["Make the verify+consume step idempotent per token: disable double submits, key AJAX retries to the same challenge.","Rescue TokenReplayed separately (it subclasses InvalidToken) and answer 409 with a re-issue path.","Set the consumed-key cache TTL longer than the challenge TTL; call mark_consumed! only after the downstream action succeeds."],"tags":["authentication","jwt","replay-protection","cache","otp","ruby"],"backgroundTag":"token-replay-detected","analyzedSha":"97fad417c5a11b0eb11157890635e015723a2e97","analyzedAt":"2026-08-21T17:04:17.778Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}