Freika/dawarich · error · Auth::VerifyOtpChallengeToken::InvalidToken
blank token
Error message
blank token
What it means
Raised by Auth::VerifyOtpChallengeToken#call when the token argument is nil or empty. This service verifies a short-lived HS256 JWT issued by Auth::IssueOtpChallengeToken; a blank token can never decode, so it fails before touching JWT. Surfaced as Auth::VerifyOtpChallengeToken::InvalidToken.
Source
Thrown at app/services/auth/verify_otp_challenge_token.rb:15
# 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 => eView on GitHub (pinned to 97fad417c5)
Solutions
- Check the request that reaches the verify endpoint actually carries the challenge token (hidden input, query param, or cookie) and pass it through.
- Re-issue the challenge (restart the OTP request) when the token is missing instead of retrying verification.
- Ensure cookies for the challenge token use SameSite/secure attributes appropriate to your flow so browsers retain them across the redirect.
- In tests, generate a real token with Auth::IssueOtpChallengeToken and verify that one.
Example fix
# before user = Auth::VerifyOtpChallengeToken.new(params[:token]).call # after verifier = Auth::VerifyOtpChallengeToken.new(params[:token].to_s) if params[:token].blank? return redirect_to new_otp_request_path, alert: 'Challenge expired - request a new code' end user = verifier.call
Defensive patterns
Strategy: validation
Validate before calling
if params[:token].blank? return redirect_to new_challenge_path, alert: 'Challenge missing - request a new code' end
Type guard
def challenge_token?(t) = t.is_a?(String) && t.split('.').length == 3 Try / catch
begin
user = Auth::VerifyOtpChallengeToken.new(token).call
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
render json: { error: e.message }, status: :unauthorized
end Prevention
- Treat the challenge token as required state: render it into the OTP form (hidden field) or a first-party cookie each time a code is sent.
- When it is missing, restart the flow instead of retrying verification.
- Use real Auth::IssueOtpChallengeToken output in tests.
When it happens
Trigger: Calling VerifyOtpChallengeToken.new(token).call with a missing params[:token], an email-OTP flow where the user submits the code page without the challenge token cookie/param, or a client that loses the token between issuing the challenge and verifying it.
Common situations: Challenge token stored in a cookie that was cleared or blocked (ITP, third-party cookie rules), form posts to the verify endpoint without a hidden token field, deep links into the OTP screen that never carried the token, test harnesses calling the verifier directly with nil.
Related errors
AI-assisted analysis of Freika/dawarich@97fad417c5 (2026-08-21).
Data as JSON: /api/errors/eb918acc2bcb7f50.
Report an issue: GitHub.