{"record":{"id":"46c4393e3c735d7d","repo":"Freika/dawarich","slug":"wrong-purpose","errorCode":null,"errorMessage":"wrong purpose","messagePattern":"wrong purpose","errorType":"exception","errorClass":"Auth::VerifyOtpChallengeToken::InvalidToken","httpStatus":401,"severity":"error","filePath":"app/services/auth/verify_otp_challenge_token.rb","lineNumber":18,"sourceCode":"# frozen_string_literal: true\n\nmodule Auth\n  class VerifyOtpChallengeToken\n    class InvalidToken < StandardError; end\n    class TokenReplayed < InvalidToken; end\n\n    CONSUMED_KEY_PREFIX = 'otp_challenge:consumed:'\n\n    def initialize(token)\n      @token = token\n    end\n\n    def call\n      raise InvalidToken, 'blank token' if @token.blank?\n\n      decoded, = JWT.decode(@token, Auth::InternalTokenSecret.call, true, algorithm: 'HS256')\n      raise InvalidToken, 'wrong purpose' unless decoded['purpose'] == 'otp_challenge'\n      raise InvalidToken, 'missing jti' if decoded['jti'].blank?\n\n      if decoded['iat'].present? &&\n         (Time.now.to_i - decoded['iat'].to_i) > Auth::IssueOtpChallengeToken::TTL.to_i\n        raise InvalidToken, 'token too old'\n      end\n\n      raise TokenReplayed, 'token already consumed' if token_consumed?(decoded['jti'])\n\n      user = User.find_by(id: decoded['user_id'])\n      raise InvalidToken, 'user not found' unless user\n\n      @jti = decoded['jti']\n      user\n    rescue JWT::DecodeError => e\n      raise InvalidToken, e.message\n    end\n","sourceCodeStart":1,"sourceCodeEnd":36,"githubUrl":"https://github.com/Freika/dawarich/blob/97fad417c5a11b0eb11157890635e015723a2e97/app/services/auth/verify_otp_challenge_token.rb#L1-L36","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# before: reusing an unrelated internal token\nAuth::VerifyOtpChallengeToken.new(session[:magic_link_token]).call # -> 'wrong purpose'\n\n# after: use the token from the OTP challenge issuer\nchallenge = Auth::IssueOtpChallengeToken.new(user).call # sets purpose 'otp_challenge'\nAuth::VerifyOtpChallengeToken.new(challenge).call","handlingStrategy":"try-catch","validationCode":"payload, = JWT.decode(token, nil, false) rescue nil\nusable = payload.is_a?(Hash) && payload['purpose'] == 'otp_challenge' && payload['jti'].present?\nrestart_flow! unless usable","typeGuard":"def otp_challenge_token?(payload) = payload['purpose'] == 'otp_challenge' && payload['jti'].present?","tryCatchPattern":"begin\n  Auth::VerifyOtpChallengeToken.new(token).call\nrescue Auth::VerifyOtpChallengeToken::InvalidToken => e\n  redirect_to new_challenge_path, alert: 'Code session invalid - start again'\nend","preventionTips":["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."],"tags":["authentication","jwt","claims","otp","ruby"],"backgroundTag":"jwt-claim-validation-failed","analyzedSha":"97fad417c5a11b0eb11157890635e015723a2e97","analyzedAt":"2026-08-21T17:04:17.778Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}