Freika/dawarich · error · Auth::VerifyOtpChallengeToken::InvalidToken

missing jti

Error message

missing jti

What it means

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.

Source

Thrown at app/services/auth/verify_otp_challenge_token.rb:19

# frozen_string_literal: true

module Auth
  class VerifyOtpChallengeToken
    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!

View on GitHub (pinned to 97fad417c5)

Solutions

  1. Ensure every otp_challenge token is created via Auth::IssueOtpChallengeToken so the payload includes a jti (SecureRandom-based unique ID).
  2. Decode the failing token (JWT.decode(token, nil, false)) and confirm 'jti' is present; if not, find who minted it.
  3. Purge/expire legacy tokens that lack jti and force users to request a new OTP.
  4. Add a spec asserting issued tokens contain purpose, jti, iat, and user_id.

Example fix

# before: hand-rolled token, no jti
JWT.decode(token, nil, false).first # => {"purpose"=>"otp_challenge", "user_id"=>1} # -> 'missing jti'

# after: always mint through the issuer
Auth::IssueOtpChallengeToken.new(user).call # payload includes jti: SecureRandom.uuid
Defensive patterns

Strategy: try-catch

Validate before calling

payload, = JWT.decode(token, nil, false) rescue nil
payload.is_a?(Hash) && payload['jti'].present? || restart_flow!

Type guard

def has_jti?(payload) = payload.is_a?(Hash) && payload['jti'].to_s.present?

Try / catch

begin
  Auth::VerifyOtpChallengeToken.new(token).call
rescue Auth::VerifyOtpChallengeToken::InvalidToken
  restart_flow! # re-issue challenge, new token
end

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of Freika/dawarich@97fad417c5 (2026-08-21). Data as JSON: /api/errors/849a4924e779b081. Report an issue: GitHub.