{"record":{"id":"fe7ae9f7f22f9ebc","repo":"Freika/dawarich","slug":"token-too-old","errorCode":null,"errorMessage":"token too old","messagePattern":"token too old","errorType":"http","errorClass":"Auth::VerifyOtpChallengeToken::InvalidToken","httpStatus":401,"severity":"error","filePath":"app/services/auth/verify_otp_challenge_token.rb","lineNumber":23,"sourceCode":"    class InvalidToken < StandardError; end\n    class TokenReplayed < InvalidToken; end\n\n    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}\",","sourceCodeStart":5,"sourceCodeEnd":41,"githubUrl":"https://github.com/Freika/dawarich/blob/97fad417c5a11b0eb11157890635e015723a2e97/app/services/auth/verify_otp_challenge_token.rb#L5-L41","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# before: submit with a stale stored token\nAuth::VerifyOtpChallengeToken.new(params[:token]).call # -> 'token too old' after TTL\n\n# after: catch expiry and re-issue\nbegin\n  user = Auth::VerifyOtpChallengeToken.new(params[:token]).call\nrescue Auth::VerifyOtpChallengeToken::InvalidToken => e\n  redirect_to new_otp_challenge_path, alert: 'Code expired - request a new one' if e.message == 'token too old'\nend","handlingStrategy":"try-catch","validationCode":"# Client-side: proactively refresh before submitting\nif (Date.now() / 1000) - issuedAtSeconds > TTL_SECONDS * 0.8 { requestNewChallenge(); }","typeGuard":null,"tryCatchPattern":"begin\n  user = Auth::VerifyOtpChallengeToken.new(token).call\nrescue Auth::VerifyOtpChallengeToken::InvalidToken => e\n  if e.message == 'token too old'\n    redirect_to new_challenge_path, alert: 'Code expired - request a new one'\n  else\n    render json: { error: e.message }, status: :unauthorized\n  end\nend","preventionTips":["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."],"tags":["authentication","jwt","token-expiry","otp","ruby"],"backgroundTag":"jwt-token-expired","analyzedSha":"97fad417c5a11b0eb11157890635e015723a2e97","analyzedAt":"2026-08-21T17:04:17.778Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}