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