Search code examples
ruby-on-railsruby-on-rails-3randomguid

Best way to create unique token in Rails?


Here's what I'm using. The token doesn't necessarily have to be heard to guess, it's more like a short url identifier than anything else, and I want to keep it short. I've followed some examples I've found online and in the event of a collision, I think the code below will recreate the token, but I'm not real sure. I'm curious to see better suggestions, though, as this feels a little rough around the edges.

def self.create_token
    random_number = SecureRandom.hex(3)
    "1X#{random_number}"

    while Tracker.find_by_token("1X#{random_number}") != nil
      random_number = SecureRandom.hex(3)
      "1X#{random_number}"
    end
    "1X#{random_number}"
  end

My database column for the token is a unique index and I'm also using validates_uniqueness_of :token on the model, but because these are created in batches automatically based on a user's actions in the app (they place an order and buy the tokens, essentially), it's not feasible to have the app throw an error.

I could also, I guess, to reduce the chance of collisions, append another string at the end, something generated based on the time or something like that, but I don't want the token to get too long.


Solution

  • -- Update EOY 2022 --

    It's been some time since I answered this. So much so that I've not even taken a look at this answer for ~7 years. I have also seen this code used in many organizations that rely on Rails to run their business.

    TBH, these days I wouldn't consider my earlier solution, or how Rails implemented it, a great one. Its uses callbacks which can be PITA to debug and is pessimistic 🙁 in nature, even though there is a very low chance of collision for SecureRandom.urlsafe_base64. This holds true for both long and short-lived tokens.

    What I would suggest as a potentially better approach is to be optimistic 😊 about it. Set a unique constraint on the token in the database of choice and then just attempt to save it. If saving produces an exception, retry until it succeeds.

    class ModelName < ActiveRecord::Base
      def persist_with_random_token!(attempts = 10)
        retries ||= 0
        self.token = SecureRandom.urlsafe_base64(nil, false)
        save!
      rescue ActiveRecord::RecordNotUnique => e
        raise if (retries += 1) > attempts
    
        Rails.logger.warn("random token, unlikely collision number #{retries}")
        retry
      end
    end
    

    What is the result of this?

    • One query less as we are not checking for the existence of the token beforehand.
    • Quite a bit faster, overall because of it.
    • Not using callbacks, which makes debugging easier.
    • There is a fallback mechanism if a collision happens.
    • A log trace (metric) if a collision does happen
      • Is it time to clean old tokens maybe,
      • or have we hit the unlikely number of records when we need to go to SecureRandom.urlsafe_base64(32, false)?).

    -- Update --

    As of January 9th, 2015. the solution is now implemented in Rails 5 ActiveRecord's secure token implementation.

    -- Rails 4 & 3 --

    Just for future reference, creating safe random token and ensuring it's uniqueness for the model (when using Ruby 1.9 and ActiveRecord):

    class ModelName < ActiveRecord::Base
    
      before_create :generate_token
    
      protected
    
      def generate_token
        self.token = loop do
          random_token = SecureRandom.urlsafe_base64(nil, false)
          break random_token unless ModelName.exists?(token: random_token)
        end
      end
    
    end
    

    Edit:

    @kain suggested, and I agreed, to replace begin...end..while with loop do...break unless...end in this answer because previous implementation might get removed in the future.

    Edit 2:

    With Rails 4 and concerns, I would recommend moving this to concern.

    # app/models/model_name.rb
    class ModelName < ActiveRecord::Base
      include Tokenable
    end
    
    # app/models/concerns/tokenable.rb
    module Tokenable
      extend ActiveSupport::Concern
    
      included do
        before_create :generate_token
      end
    
      protected
    
      def generate_token
        self.token = loop do
          random_token = SecureRandom.urlsafe_base64(nil, false)
          break random_token unless self.class.exists?(token: random_token)
        end
      end
    end