How to implement basic Defensio spam protection in Rails
Right off the bat I should say that, like most Ruby applications, there are a ton of different ways this could be achieved. But there didn’t seem to be too many examples floating around, so I thought I would explain how I’m doing it on this site.
For those who don’t know what Defensio is, let me explain. It’s a simple web service that essentially filters any content you give it in order to determine if it’s “spammy” or not. Basically, you give it some content and it spits back some attributes that you can use to base decisions on.
There are 3 key attributes that you’ll find very useful: allow, profanity-match, and spaminess.
allow– returns true/false depending on whether or not Defensio thinks this piece of content should be allowed on your siteprofanity-match– returns true/false based on, well, whether or not the content has profanity in itspaminess– returns a Float value between 0 and 1 indicating how “spammy” the content is (1 would be 100% spam).
Based on these attributes, you can setup some rules to handle the flow of your comments. Here’s essentially what my rules are:
- Automatically approve the comment and allow it to go through if Defensio sets
allowto true,profanity-matchto false, and aspaminessvalue between 0 and 0.10 - Put the comment in an approval queue if any one of the above things fail, and the
spaminessis less than 0.75 - Automatically reject the comment if the
spaminessis greater than 0.75, regardless of what theallowandprofanity-matchvalues say
With that said, here’s basically how I have things setup. Firstly…
1 | $> sudo gem install defensio |
It’s pretty simple to interface with the Defensio API without the gem, but why not use it, right?
In my comment model I have a before_create :check_for_spam callback that hands the comment over to Defensio. I added a DefensioResponse class that I pass the returned attributes to. That’s also where I keep the rules/logic. Here are the noteworthy pieces of my comment model:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | class Comment < ActiveRecord::Base has_one :defensio_response attr_accessor :defensio_signature before_create :check_for_spam after_create :associate_with_defensio_response attr_protected :approved protected def check_for_spam result = self.class.defensio.post_document(self.defensio_attributes) status, attributes = result.first, result.last return false unless status == 200 defensio_response = DefensioResponse.init(attributes) self.defensio_signature = defensio_response.signature self.approved = defensio_response.approved? defensio_response.proceed? end def associate_with_defensio_response DefensioResponse.find_by_signature(self.defensio_signature).update_attribute(:comment_id, self.id) end def self.defensio @@defensio ||= Defensio.new(self.defensio_api_key) end def self.defensio_api_key YAML::load(File.open(File.join(Rails.root, 'config', 'defensio.yml')))["api_key"] end def defensio_attributes { "type" => "comment", "platform" => "rubyonrails", "content" => self.body, "author-email" => self.email, "author-name" => self.name, "author-url" => self.site } end public def approve! self.update_attribute(:approved, true) self.class.defensio.put_document(signature, { :allow => true }) end def mark_as_spam! self.update_attribute(:approved, false) self.class.defensio.put_document(signature, { :allow => false }) end def signature self.defensio_response.signature end end |
When you post a document to Defensio, it returns an array of two values. The first is the status code and the second is the actual output of the response (as a hash of attributes).
You can see in the check_for_spam method that I’m setting the self.approved flag based on the logic I have in my defensio_response. Like I said, if a comment looks good according to my rules, then I don’t want to be bothered with approving it and let it pass through.
For the missing pieces, here are the internals of my DefensioResponse class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | class DefensioResponse < ActiveRecord::Base belongs_to :comment validates_presence_of :allow, :spaminess, :signature AUTOMATIC_APPROVAL_RANGE = 0.0..0.1 AUTOMATIC_DENIAL_VALUE = 0.75 private def self.clean!(attrs) returning({}) do |cleansed_attributes| attrs.each do |key, value| cleansed_attributes.merge!(key.to_s.underscore => value) end end end public def self.init(attributes) self.new(clean!(attributes)) end def approved? self.allow? && self.no_profanity? && AUTOMATIC_APPROVAL_RANGE.include?(self.spaminess) end def no_profanity? !self.profanity_match? end def proceed? return false if self.spaminess > AUTOMATIC_DENIAL_VALUE self.save! end end |
All the clean!(attrs) method does is change the response values, which use dashes (i.e. “profanity-match”) to use underscores (i.e. “profanity_match”), since that’s what ActiveRecord prefers.
Also, as you can see the approved? method only returns true if all 3 of my requirements are true. That’s the only way a comment will automatically be accepted.
In my comment model, the final line in the check_for_spam method is a call to the defensio_response.proceed? method. Remember, in Rails, if you return false from a callback then the entire transaction is canceled. So, in the proceed? method, if the spaminess is simply too high, I’m just returning false and canceling everything (no comment or response get saved). But if it’s good to go, I’ll let the self.save! call determine if things should be committed, because really, I don’t want a comment without a defensio response.
Improving Over Time
From the code above, the only thing the defensio gem provides is the post_document and put_document methods, which reflect a RESTful API. You may understand why you would POST a document, but why would you PUT (read: update) one? It’s simple: to report false positives/negatives.
Defensio uses a learning algorithm, and like anything that learns, you have to teach it so it becomes smarter. If you look in my comment model you’ll notice two methods: approve! and mark_as_spam!. Remember, if a comment falls between 0.1 and 0.75 then it goes into the “needs my approval” queue.
If I approve the comment, it updates the flag in the comments table, but it also tells Defensio that “this is a good comment”. Likewise, if I mark one as spam, it updates the flag and tells Defensio that “this is a bad comment”.
That’s one reason why I’m saving a Defensio response with each comment, so I have the response signature to identify the comment later on. The signature is the only common piece of information between the content on Defensio and the comment on this site.
Results
So far, Defensio has been doing great. I’ve only been using it for a few days, and here’s what it has done so far:

Already has prevented 333 spam comments at 96.07% accuracy? I’ll take that.
Like I said, I didn’t find much in terms of examples of how others approached this, so hopefully someone will find this useful. If you’re having spam trouble, I’d take a close look at Defensio. They have a simple API and an ever-increasing level of accuracy. So far I’m very pleased.

Carl Mercier Monday, 25 Jan, 2010 Posted at 10:40AM
This is a great article, Ryan!
There are a few things that I suggest you change to get the most out of Defensio, however.
- You should not “Automatically reject the comment if the spaminess is greater than 0.75, regardless of what the allow and profanity-match values say”. We strongly advice against that. Technically, a document over 75% spaminess should be spam, but you should always trust our “allow” value.
- You should be more precise when setting “platform”. “rubyonrails” is too generic. The platform field helps us identify some spam that is targeted towards different blogging platforms. I guess in your case this is a little bit harder since you built your own blogging platform, but this is something we advice people to do in general.
- When we say “profanity” = true, you can do a POST to “profanity-filter” to mask profanity with *. Or you might opt to reject the document altogether as you’re doing now.
- Asynchronous calls to Defensio are faster and more accurate. Therefore, they are strongly encouraged. Providing a callback Url is the best way to implement this.
Thanks!
Ryan Monday, 25 Jan, 2010 Posted at 11:14AM
Carl Mercier -
I’ll definitely take a look at making your suggested changes. I want to make sure I’m getting the most out of Defensio. Once I update the code, I’ll make the tweaks in this post to reflect your suggestions.
Thanks!
Chad Sunday, 04 Jul, 2010 Posted at 11:19PM
useful article. ironically, you have a captcha on the site now… was it because defensio wasn’t good enough? or do you need both?
Ryan Monday, 05 Jul, 2010 Posted at 09:01AM
Chad -
Well, Defensio worked (and works) great for identifying potential spam, the problem was it was “unsure” about too many. At least, more than I wanted to deal with. I was getting about 30 comments a day. Defensio prevented them from going through, but it then required a “yes” or “no” from me to approve or mark as spam. If I let it go for 3-4 days all of a sudden I could have 150 comments that I needed to go through. I added the captcha back in to help weed those out.
Korben Dallas Friday, 28 Jan, 2011 Posted at 10:43AM
Thank you very much for this article ! I think a complete example on the Defensio site would be appropriate. I would add 2 points :
1. Change
returning({})byHash.new.tapfor Ruby 1.9 compliant2. The following migration which is not in your example :