{"record":{"id":"849a4924e779b081","repo":"Freika/dawarich","slug":"missing-jti","errorCode":null,"errorMessage":"missing jti","messagePattern":"missing jti","errorType":"exception","errorClass":"Auth::VerifyOtpChallengeToken::InvalidToken","httpStatus":401,"severity":"error","filePath":"app/services/auth/verify_otp_challenge_token.rb","lineNumber":19,"sourceCode":"# frozen_string_literal: true\n\nmodule Auth\n  class VerifyOtpChallengeToken\n    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!","sourceCodeStart":1,"sourceCodeEnd":37,"githubUrl":"https://github.com/Freika/dawarich/blob/97fad417c5a11b0eb11157890635e015723a2e97/app/services/auth/verify_otp_challenge_token.rb#L1-L37","documentation":"Raised by Auth::VerifyOtpChallengeToken#call when the decoded JWT has no 'jti' (JWT ID) claim. The jti is the unique token identifier used later by mark_consumed! to write a one-time-use marker into Rails.cache ('otp_challenge:consumed:<jti>'); without it, replay protection is impossible, so the service rejects the token outright.","triggerScenarios":"The token was signed with the correct secret and purpose but minted without a jti — typically a hand-rolled JWT.encode call, a modified/custom issuer, or a token from an older version of Auth::IssueOtpChallengeToken before jti was added.","commonSituations":"Scripts or test factories building tokens directly with JWT.encode instead of the issuer service, another service in the app reusing the internal secret but a different payload shape, tokens issued before an upgrade still being verified after it.","solutions":["Ensure every otp_challenge token is created via Auth::IssueOtpChallengeToken so the payload includes a jti (SecureRandom-based unique ID).","Decode the failing token (JWT.decode(token, nil, false)) and confirm 'jti' is present; if not, find who minted it.","Purge/expire legacy tokens that lack jti and force users to request a new OTP.","Add a spec asserting issued tokens contain purpose, jti, iat, and user_id."],"exampleFix":"# before: hand-rolled token, no jti\nJWT.decode(token, nil, false).first # => {\"purpose\"=>\"otp_challenge\", \"user_id\"=>1} # -> 'missing jti'\n\n# after: always mint through the issuer\nAuth::IssueOtpChallengeToken.new(user).call # payload includes jti: SecureRandom.uuid","handlingStrategy":"try-catch","validationCode":"payload, = JWT.decode(token, nil, false) rescue nil\npayload.is_a?(Hash) && payload['jti'].present? || restart_flow!","typeGuard":"def has_jti?(payload) = payload.is_a?(Hash) && payload['jti'].to_s.present?","tryCatchPattern":"begin\n  Auth::VerifyOtpChallengeToken.new(token).call\nrescue Auth::VerifyOtpChallengeToken::InvalidToken\n  restart_flow! # re-issue challenge, new token\nend","preventionTips":["Only Auth::IssueOtpChallengeToken may mint these tokens; forbid ad-hoc JWT.encode calls for the otp purpose.","Add issuer-service specs asserting the payload contract (purpose, jti, iat, user_id).","Consider verifying jti presence inside the issuer's own tests as a canary."],"tags":["authentication","jwt","jti","otp","ruby"],"backgroundTag":"jwt-claim-validation-failed","analyzedSha":"97fad417c5a11b0eb11157890635e015723a2e97","analyzedAt":"2026-08-21T17:04:17.778Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}