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:
- A controller concern that primarily aims to cover two things:
- The question of “is the current visitor logged in?”
- The business of actually signing someone in
- 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