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 => e

View on GitHub (pinned to 97fad417c5)

Solutions

  1. Check the request that reaches the verify endpoint actually carries the challenge token (hidden input, query param, or cookie) and pass it through.
  2. Re-issue the challenge (restart the OTP request) when the token is missing instead of retrying verification.
  3. Ensure cookies for the challenge token use SameSite/secure attributes appropriate to your flow so browsers retain them across the redirect.
  4. 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

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.