Rails 8 DIY Auth: It's So Good.

Jon Sully

3 Minutes

100% Human-Written

Some kind of description

A preface: you’ve been able to roll your own auth in Rails for a long time. Rails 8 simply brings two things: first, a generator that scaffolds up authentication basics for you. While that’s handy at face value, it’s actually more important as a guide and oracle; Rails now has a ‘happy path’ that says, “here’s the right way to do authentication”. Second, Rails 8 formalizes the shift in the Rails core team’s mentality around auth. The ethos of auth in Rails is now (informally but clearly) “just roll your own”:

No need to fear rolling your own authentication setup

DHH

So, with that out of the way, what I want to show you is how easy it is to roll your own auth in Rails 8 when using email-token login only. Our team has opted to stick to email-token-only authentication as a pseudo-2-factor thing, and that ultimately made our auth setup ridiculously simple.

I’m not going to walk through the generators and the generation process — we’ll leave that as an exercise for the reader. Instead, I just want to show you the resulting code, most of which is 90-95% the same as what would be generated by the Rails 8 generators.

Let’s Dive In!

This auth setup has two sides:

  1. A controller concern that primarily aims to cover two things:
    1. The question of “is the current visitor logged in?”
    2. The business of actually signing someone in
  2. A “sign in” controller that determines whether the current visitor should get logged in as a given user

And a third piece that simply turns a given database record / object into a secure token and vice versa. We’ll actually dive into that first as a simple foundation for security!

AuthToken

Here’s the code in full, then we’ll talk through it:

module AuthToken
  extend ActiveSupport::Concern

  # NOTE: Sourced from https://github.com/devise-passwordless/devise-passwordless/blob/19b0c849e8a6a7bf5b26bdb4f9f4f5ff4dbb0a6c/lib/devise/passwordless/tokenizers/message_encryptor_tokenizer.rb#L3
  def self.generate_secure_token(user)
    now = Time.current
    len = ActiveSupport::MessageEncryptor.key_len
    salt = Rails.application.config.action_dispatch.encrypted_cookie_salt
    key = ActiveSupport::KeyGenerator.new(Rails.application.config.secret_key_base).generate_key(salt, len)
    crypt = ActiveSupport::MessageEncryptor.new(key, serializer: JSON)
    data = {
      id: user.id,
      created_at: now.to_f
    }
    encrypted_data = crypt.encrypt_and_sign(data)
    salt_base64 = Base64.strict_encode64(salt)
    "#{salt_base64}:#{encrypted_data}"
  end

  # NOTE: Returns two potential objects — the first is either the user object the token
  #   validly represented, or nil if the token was invalid for any reason. The second is, in the case
  #   that the token was expired or already used, the user the requestor was _trying_ to auth as.
  def self.decode_secure_token(token)
    return [nil, nil] if token.blank?
    salt_base64, encrypted_data = token.split(":")
    return [nil, nil] if salt_base64.blank? || encrypted_data.blank?

    begin
      salt = Base64.strict_decode64(salt_base64)
    rescue ArgumentError
      return [nil, nil]
    end

    len = ActiveSupport::MessageEncryptor.key_len
    key = ActiveSupport::KeyGenerator.new(Rails.application.config.secret_key_base).generate_key(salt, len)
    crypt = ActiveSupport::MessageEncryptor.new(key, serializer: JSON)

    begin
      decrypted_data = crypt.decrypt_and_verify(encrypted_data)
    rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage
      return [nil, nil]
    end
    return [nil, nil] unless decrypted_data["id"].present?
    return [nil, nil] unless (user = User.find_by(id: decrypted_data["id"]))

    # Tokens only valid for up to 72 hours (for the first click)
    token_created_at = ActiveSupport::TimeZone["UTC"].at(decrypted_data["created_at"])
    return [nil, user] if token_created_at < 72.hours.ago

    # Recently created tokens can be re-used for a few minutes after the person logs in (see large note below)
    if (latest_session = authenticatable.sessions.order(created_at: :desc).first)
      return [nil, user] if (token_created_at + 10.minutes) < latest_session.created_at
    end

    [user, user]
  end
end

Let’s start on the encoding side since it’s simpler:

First, this is all heavily cribbed from devise-passwordless, a gem we used to accomplish email-token-style login with Devise. Since the concept of encoding and decoding a secure token is universal, the code in this library is applicable and useful here!

We start with some object setup, leaning heavily on Rails’ own encryption and salting mechanisms:

now = Time.current
len = ActiveSupport::MessageEncryptor.key_len
salt = Rails.application.config.action_dispatch.encrypted_cookie_salt
key = ActiveSupport::KeyGenerator.new(Rails.application.config.secret_key_base).generate_key(salt, len)
crypt = ActiveSupport::MessageEncryptor.new(key, serializer: JSON)

Then we prepare the simple attributes hash to be encoded into the token (the ID of the user object we passed in and the timestamp of the token creation):

data = {
  id: user.id,
  created_at: now.to_f
}

And finally we encrypt the hash, encode the salt, and put them both together to make the final token:

encrypted_data = crypt.encrypt_and_sign(data)
salt_base64 = Base64.strict_encode64(salt)
"#{salt_base64}:#{encrypted_data}"

That output will look something like this:

user = User.last
AuthToken.generate_secure_token(user)
#=> VVlMejdWVUFCcWZyRW92TFNPallBaUlTa0JZa21ZS1U3TVF1RkszWFpWcHhVYnNNT0w1S2laTUhMdHMyMFVzS3F2Rk1iOThOQlQ0MHlvdFhDRG5tQzI1TXp3cjFzN24xdnE2RzJBam44VG5YZ2I2NEpidnNKblBzbGNIVlNLb3A=:CEmeFakZFtCNpN/Lrm6V1VYavhRjBE2fPAUV3dHbuoyvdaXH2eNMDTY=--3CwiyvE/+55rOJgB--9GfmbH4b3YLrandomYQDUYF0F3JQ1==

Now we need the ability to decode that string back into a User record!

A note on the outset — this method started with a simpler idea: return the User object that the token mapped to if the token was valid, otherwise return nil. I realized later on that

Comments? Thoughts?

Please note: spam comments happen a lot. All submitted comments are run through OpenAI to detect and block spam.