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

user not found

Error message

user not found

What it means

Raised by Auth::VerifyOtpChallengeToken#call when the JWT is fully valid (signature, purpose, jti, freshness) but User.find_by(id: decoded['user_id']) returns nil. The token references a user that no longer exists in the database — typically a deleted account or a token minted against a different database/environment.

Source

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

      @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

    def mark_consumed!
      return false if @jti.blank?

      Rails.cache.write(
        "#{CONSUMED_KEY_PREFIX}#{@jti}",
        true,
        expires_in: Auth::IssueOtpChallengeToken::TTL,
        unless_exist: true
      )
    end

View on GitHub (pinned to 97fad417c5)

Solutions

  1. Treat it as terminal for that token: clear the client-side challenge and ask the user to restart authentication (account no longer exists).
  2. If it happens after a database restore/reset, note that in-flight tokens are invalidated by design — let them age out.
  3. If it appears in production for real users, check whether an account-deletion job ran and whether other flows reference deleted users.
  4. Ensure each environment has its own Auth::InternalTokenSecret so tokens cannot cross environments.

Example fix

# before
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
  render json: { error: e.message }, status: :unauthorized # opaque 'user not found'

# after: translate to user-facing restart
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
  if e.message == 'user not found'
    render json: { error: 'Account not found. Please sign up again.' }, status: :not_found
  else
    render json: { error: e.message }, status: :unauthorized
  end
Defensive patterns

Strategy: try-catch

Try / catch

begin
  user = Auth::VerifyOtpChallengeToken.new(token).call
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
  if e.message == 'user not found'
    render json: { error: 'Account no longer exists' }, status: :not_found
  else
    render json: { error: e.message }, status: :unauthorized
  end
end

Prevention

When it happens

Trigger: User requested an OTP, then their account was deleted (GDPR erasure, admin cleanup) before submitting the code; a token from a staging environment replayed against production whose user IDs differ; test fixtures that sign tokens for user IDs that were never created.

Common situations: Account deletion mid-flow, database resets/restores while challenges are in flight, shared secrets across environments (Auth::InternalTokenSecret identical in staging and prod) letting cross-environment tokens verify, seeds changed between issue and verify.

Related errors


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