Freika/dawarich · warning · Auth::VerifyOtpChallengeToken::TokenReplayed

token already consumed

Error message

token already consumed

What it means

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.

Source

Thrown at app/services/auth/verify_otp_challenge_token.rb:26

    CONSUMED_KEY_PREFIX = 'otp_challenge:consumed:'

    def initialize(token)
      @token = token
    end

    def call
      raise InvalidToken, 'blank token' if @token.blank?

      decoded, = JWT.decode(@token, Auth::InternalTokenSecret.call, true, algorithm: 'HS256')
      raise InvalidToken, 'wrong purpose' unless decoded['purpose'] == 'otp_challenge'
      raise InvalidToken, 'missing jti' if decoded['jti'].blank?

      if decoded['iat'].present? &&
         (Time.now.to_i - decoded['iat'].to_i) > Auth::IssueOtpChallengeToken::TTL.to_i
        raise InvalidToken, 'token too old'
      end

      raise TokenReplayed, 'token already consumed' if token_consumed?(decoded['jti'])

      user = User.find_by(id: decoded['user_id'])
      raise InvalidToken, 'user not found' unless user

      @jti = decoded['jti']
      user
    rescue JWT::DecodeError => e
      raise InvalidToken, e.message
    end

    def mark_consumed!
      return false if @jti.blank?

      Rails.cache.write(
        "#{CONSUMED_KEY_PREFIX}#{@jti}",
        true,
        expires_in: Auth::IssueOtpChallengeToken::TTL,
        unless_exist: true

View on GitHub (pinned to 97fad417c5)

Solutions

  1. On the client, disable the submit button and make the verify request idempotent per challenge (send once, treat network errors with a fresh challenge).
  2. On the server, rescue TokenReplayed separately from InvalidToken and respond 409/'already used' so the UI can prompt for a new code.
  3. 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.
  4. Ensure cache TTL for consumed keys exceeds the challenge TTL so entries do not evaporate while tokens are still temporally valid.

Example fix

# before: rescue everything as generic invalid
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
  render json: { error: e.message }, status: :unauthorized

# after: distinguish replay
rescue Auth::VerifyOtpChallengeToken::TokenReplayed
  render json: { error: 'This code was already used. Request a new one.' }, status: :conflict
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
  render json: { error: e.message }, status: :unauthorized
Defensive patterns

Strategy: try-catch

Validate before calling

# Close the race at consumption time with an atomic add
key = "#{Auth::VerifyOtpChallengeToken::CONSUMED_KEY_PREFIX}#{jti}"
first_use = Rails.cache.redis.set(key, '1', nx: true, ex: ttl) # only first writer wins

Try / catch

begin
  verifier = Auth::VerifyOtpChallengeToken.new(token)
  user = verifier.call
  verifier.mark_consumed!
rescue Auth::VerifyOtpChallengeToken::TokenReplayed
  render json: { error: 'Code already used - request a new one' }, status: :conflict
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
  render json: { error: e.message }, status: :unauthorized
end

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of Freika/dawarich@97fad417c5 (2026-08-21). Data as JSON: /api/errors/ef49a7a8a9da21ab. Report an issue: GitHub.