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

wrong purpose

Error message

wrong purpose

What it means

Raised by Auth::VerifyOtpChallengeToken#call when the decoded HS256 JWT's 'purpose' claim is not the string 'otp_challenge'. The app signs several internal token types with the same secret (Auth::InternalTokenSecret), so the purpose claim is what stops one token type (e.g. a session or email token) from being replayed against the OTP endpoint. The signature verified fine; the token is simply the wrong kind.

Source

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

# 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

View on GitHub (pinned to 97fad417c5)

Solutions

  1. Confirm the token was produced by Auth::IssueOtpChallengeToken (which sets purpose: 'otp_challenge') and not another issuer service.
  2. Decode and inspect: JWT.decode(token, nil, false).first['purpose'] to see what the token actually claims to be.
  3. If you changed the purpose string or token structure, invalidate old in-flight tokens and force clients to restart the flow.
  4. Route each endpoint to accept only its own token param so tokens cannot be mixed.

Example fix

# before: reusing an unrelated internal token
Auth::VerifyOtpChallengeToken.new(session[:magic_link_token]).call # -> 'wrong purpose'

# after: use the token from the OTP challenge issuer
challenge = Auth::IssueOtpChallengeToken.new(user).call # sets purpose 'otp_challenge'
Auth::VerifyOtpChallengeToken.new(challenge).call
Defensive patterns

Strategy: try-catch

Validate before calling

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

Type guard

def otp_challenge_token?(payload) = payload['purpose'] == 'otp_challenge' && payload['jti'].present?

Try / catch

begin
  Auth::VerifyOtpChallengeToken.new(token).call
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
  redirect_to new_challenge_path, alert: 'Code session invalid - start again'
end

Prevention

When it happens

Trigger: Passing a JWT issued for a different flow (password reset, magic link, session token) into the OTP verification endpoint; hand-building a token with JWT.encode that omits purpose 'otp_challenge'; stale token from a previous scheme where the claim was absent.

Common situations: Frontend reuses a stored token of the wrong type after a flow change, an endpoint accepts a generic token param and clients send whichever token they have, refactoring token issuance and renaming/omitting the purpose claim while old tokens are still in flight.

Related errors


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