fbpx

Many web applications have the need to send links to users that allow the user to perform an action without logging in. Examples include password reset and confirming an email address. These links need to embed some kind of identifying information so the server can discern which user is performing the action. If the action is sensitive, the link should also include some amount of obfuscation so the URLs cannot be guessed or generated by nefarious actors.

We have a few ways to generate links that serve this purpose. Many applications start with something like Rails’ has_secure_token. This helper wraps up the logic to generate and persist tokens in a datastore. This approach is well understood and easy to reason about.

What are some of the downsides of storing tokens on the server? Storing the tokens in plaintext means if an attacker gets access to the application’s database, all of the stored tokens are exposed. Hashing (or digesting) the tokens asymmetrically fixes this issue but we still have to persist and protect this data.

What if we don’t need to persist the token at all? What if we could generate a token, send it out in a link, and then verify it when it gets sent back to us? In this way, we don’t have to store the tokens, which means there is less to protect.

We can achieve this stateless approach with encoded and signed tokens. Rails provides a mechanism to generate and verify tokens via ActiveSupport::MessageEncryptor and ActiveSupport::MessageVerifier.

We recently implemented stateless tokens in our app so users could confirm subscriptions to mailing lists. Below we’ll go through some code to get us there.

Let’s look at some code

We have a controller with a create action that allows a user to submit an email address and a confirmations action that verifies a submitted token.

class SubscriptionsController < ApplicationController
  def create
    subscription = Subscription.build(email: subscription_params[:email])

    if subscription.save
      SubscriptionsMailer.confirm_subscription(subscription.id).deliver_later  
      redirect_to subscriptions_path
    else
      redirect_to subscriptions_path
    end
  end

  def confirmations
    subscription = Subscription.verify_token_and_find(token: params[:token])

    if subscription.present?
      subscription.touch(:confirmed_at)
      flash[:notice] = "Thanks for subscribing!"
    else
      flash[:error] = "Oh no! Your token is not valid."
    end

    redirect_to subscriptions_path
  end

  private

  def subscription_params
    params.require(:subscription).permit(:email)
  end
end

And our model looks like this:

class Subscription < ApplicationRecord
  CONFIRMATION_IN_DAYS = 7
  scope :confirmed, lambda { where.not(confirmed_at: nil) }
  validates :email, uniqueness: true

  def generate_token(expiration_in_days: CONFIRMATION_IN_DAYS)
    expiration = Time.current + expiration_in_days.days
    token_values = { id: id, expiration: expiration }
    self.class.encryptor.encrypt_and_sign(token_values)
  end

  def self.verify_token_and_find(token:)
    begin
      data = encryptor.decrypt_and_verify(token)
      given_expiration = data[:expiration]

      if given_expiration < Time.current
        return nil
      end

      find(data[:id])
    rescue ActiveSupport::MessageVerifier::InvalidSignature
      nil
    end
  end

  def self.encryptor
    key_password_for_mailing_list = ENV.fetch('KEY_PASSWORD_FOR_SUBSCRIPTION')
    SUBSCRIPTION_ENCRYPTOR_KEY = ActiveSupport::KeyGenerator
      .new(key_password_for_mailing_list)
      .generate_key(salt, 32)

    ActiveSupport::MessageEncryptor.new(SUBSCRIPTION_ENCRYPTOR_KEY)
  end
end

Our mailer is relatively straightforward except for the line where we call subscription.generate_token, which does the heavy lifting for generating and signing our token:

class SubscriptionsMailer < ActionMailer::Base
  def confirm_subscription(subscription_id)
    subscription = Subscription.find(subscription_id)
    @link = confirmation_url(subscription: subscription)

    mail(
      from: "support@yourwebsite.com",
      subject: "Please Confirm Your Subscription",
      to: subscription.email
    ) do |format|
      format.text
      format.html
    end
  end

  private

  def confirmation_url(subscription:)
    subscriptions_url(token: subscription.generate_token)
  end
end

What’s the catch?

One downside of this approach is the need to manage another key, which involves protecting it (likely via using environment variables) and being able to rotate the key in an operationally simple manner. There is not a great option out of the box to manage these workflows but it’s worth considering the costs and benefits of not storing this data.

Conclusion

We wanted to find a way to avoid storing keys in our database for security and storage reasons and this approach serves our purpose. Expiring, rotating, and protecting keys still require forethought, but that’s nothing new.

Want to learn more?