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: trueView on GitHub (pinned to 97fad417c5)
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.
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
- 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.
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.