Freika/dawarich · error · Auth::VerifyOtpChallengeToken::InvalidToken
token too old
Error message
token too old
What it means
Raised by Auth::VerifyOtpChallengeToken#call when the token's 'iat' (issued-at) is older than Auth::IssueOtpChallengeToken::TTL seconds. This is a manual staleness check on top of JWT verification (the issuer does not rely on 'exp'), enforcing the short lifetime of OTP challenge tokens. Tokens newer than TTL, or without an iat, pass this check.
Source
Thrown at app/services/auth/verify_otp_challenge_token.rb:23
class InvalidToken < StandardError; end
class TokenReplayed < InvalidToken; end
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}",View on GitHub (pinned to 97fad417c5)
Solutions
- Have the client restart the flow: request a fresh OTP challenge and a new token once this error is returned.
- Check the TTL in Auth::IssueOtpChallengeToken against your UX expectations and raise it if legitimate users regularly exceed it.
- Verify NTP/time synchronization across app servers so iat deltas are not inflated by clock skew.
- Make sure the UI surfaces 'challenge expired, request a new code' instead of a generic failure.
Example fix
# before: submit with a stale stored token Auth::VerifyOtpChallengeToken.new(params[:token]).call # -> 'token too old' after TTL # after: catch expiry and re-issue begin user = Auth::VerifyOtpChallengeToken.new(params[:token]).call rescue Auth::VerifyOtpChallengeToken::InvalidToken => e redirect_to new_otp_challenge_path, alert: 'Code expired - request a new one' if e.message == 'token too old' end
Defensive patterns
Strategy: try-catch
Validate before calling
# Client-side: proactively refresh before submitting
if (Date.now() / 1000) - issuedAtSeconds > TTL_SECONDS * 0.8 { requestNewChallenge(); } Try / catch
begin
user = Auth::VerifyOtpChallengeToken.new(token).call
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
if e.message == 'token too old'
redirect_to new_challenge_path, alert: 'Code expired - request a new one'
else
render json: { error: e.message }, status: :unauthorized
end
end Prevention
- Surface a distinct 'expired, request a new code' UX instead of a generic failure.
- Show a countdown based on TTL on the OTP entry screen and auto-offer refresh.
- Keep app server clocks NTP-synced so iat deltas stay accurate.
When it happens
Trigger: User requests an OTP, sits on the entry page longer than TTL (clock time, not activity), then submits; the challenge token in the cookie/param has aged out. Also triggered by replay attempts with an old but otherwise valid token, or by server clock skew between issuing and verifying hosts.
Common situations: TTL configured too tight for real users (e.g. 2 minutes), user backgrounding the mobile app and returning later, load-balanced app servers with unsynchronized clocks, or QA environments paused mid-flow.
Related errors
AI-assisted analysis of Freika/dawarich@97fad417c5 (2026-08-21).
Data as JSON: /api/errors/fe7ae9f7f22f9ebc.
Report an issue: GitHub.