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
- Confirm the token was produced by Auth::IssueOtpChallengeToken (which sets purpose: 'otp_challenge') and not another issuer service.
- Decode and inspect: JWT.decode(token, nil, false).first['purpose'] to see what the token actually claims to be.
- If you changed the purpose string or token structure, invalidate old in-flight tokens and force clients to restart the flow.
- 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
- Mint every internal token through a dedicated issuer service so purpose claims are always correct.
- Never store multiple token types in one param/cookie slot.
- After changing token payloads, expire in-flight tokens rather than hoping they fail gracefully.
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.