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

  1. Have the client restart the flow: request a fresh OTP challenge and a new token once this error is returned.
  2. Check the TTL in Auth::IssueOtpChallengeToken against your UX expectations and raise it if legitimate users regularly exceed it.
  3. Verify NTP/time synchronization across app servers so iat deltas are not inflated by clock skew.
  4. 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

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.