ruby

Pre-build a Secure Authentication Layer with Authentication Zero for Ruby on Rails

Thomas Riboulet

Thomas Riboulet on

Pre-build a Secure Authentication Layer with Authentication Zero for Ruby on Rails

Authentication is a critical element of any web application. The Ruby on Rails ecosystem has no shortage of solutions for this topic, as no authentication layer has been backed into the framework yet.

Devise is a crowd favorite. Although it has ended up as the de-facto standard and sports many built-in features and great plugins (such as integrations with NoSQL databases, encryption, roles, and UID support), it works as a separate entity from your application.

Some have devised a different solution: a configurable generator that equips your app with an authentication scaffold. We'll dive into that option in this post.

But first, let's examine the uses of Authentication Zero.

The Purpose of Authentication Zero

It's best to avoid building authentication yourself, as there are noteworthy security concerns to consider when doing so. Authentication Zero's (not to be confused with Auth0, the other, Ruby-friendly authentication solution) approach is to give you something that follows security and Ruby on Rails best practices. You are then free to adjust some parts to fit your needs.

Yet, that also means that you now have two additional responsibilities:

  • Not to turn the provided code into a weak authentication system
  • To keep it up to date

Authentication in a web application is a way to ensure that someone coming back to your application is who they say they are and can access data related to themselves (in short). We usually use a secret linked to a user model or a third-party provider with or without a complimentary second-factor authentication.

The simplest mechanism (a pair consisting of a user identifier and a password) implies:

  • A User model with username (or email) and password fields.
  • A Users controller to handle the creation of a user account (with a page to enter details, and an action to create the user and then redirect them elsewhere).
  • A Sessions controller that shows a login form and handles both login and logout, verifying that the submitted credentials match an existing user.

Don't get fooled, though; these elements don't just cover "a few lines". Each of these three lines implies details related to security, error handling, and also, yes, a friendly User Interface. Authentication Zero gives you all this alongside plenty of advanced features such as email and password validation, email verification, API authentication, logs, rate limiting, lock mechanisms, and more.

Our Project

To demonstrate how Authentication Zero works and how to use it, we are going to integrate it into a sample Ruby on Rails application. We will start from a freshly created application and add the authentication layer with Authentication Zero. We will cover several of the key options we can use with the generator to go from very simple authentication to something using Two-Factor Authentication as well as logging, password-less authentication, and more.

We will also compare Authentication Zero with other solutions — namely Devise and Auth0 — particularly in terms of their features and usage.

Setup for a Ruby on Rails 7.1 Application

Setting up from a barebone Ruby on Rails 7.1 application is pretty standard. Simply add the authentication-zero gem to the Gemfile, run bundle, and then the generator:

Shell
rails generate authentication

Let's review the output:

Shell
gemfile bcrypt (~> 3.1.7) create db/migrate/20240512081634_create_users.rb create db/migrate/20240512081635_create_sessions.rb create app/models/current.rb create app/models/session.rb create app/models/user.rb create test/fixtures/users.yml create app/controllers/identity/email_verifications_controller.rb create app/controllers/identity/emails_controller.rb create app/controllers/identity/password_resets_controller.rb force app/controllers/application_controller.rb create app/controllers/home_controller.rb create app/controllers/passwords_controller.rb create app/controllers/registrations_controller.rb create app/controllers/sessions_controller.rb create app/views/home/index.html.erb create app/views/identity/emails/edit.html.erb create app/views/identity/password_resets/edit.html.erb create app/views/identity/password_resets/new.html.erb create app/views/passwords/edit.html.erb create app/views/registrations/new.html.erb create app/views/sessions/index.html.erb create app/views/sessions/new.html.erb create app/views/user_mailer/email_verification.html.erb create app/views/user_mailer/password_reset.html.erb create app/mailers/user_mailer.rb # routes (removed for clarity) # tests (removed for clarity)

The generator does the following:

  • Ensures that the bcrypt gem is installed.
  • Adds User and Session models, plus a Current utility class for tracking the signed‑in user.
  • Adds controllers for sign‑up, authentication, password management, and sign‑in/out.
  • Adds views where needed for those workflows.
  • Adds a mailer for email verification and password‑reset emails.

Just to let you know, we have not used additional parameters when running the generator. This is the most straightforward authentication architecture we can have, with just an email and password to authenticate a user. We will cover additional features later in the article.

We already have a working sign-up and sign-in process with email‑based password resets. The implementation is what is essential here.

First, the ApplicationController has been updated. As it's the base for any controller, it's the first thing we must look at. It now contains two before_action hooks and their methods. The first one (set_current_request_details) heavily uses the Current class to store the user agent and IP address of the current request. The other one is there to check if there is an existing session for the request and also uses the Current class. If no session exists, the request is redirected to the login screen.

So, when we try to get to http://localhost:3000, we are redirected to the login screen. A thing to note: by default, any controller inheriting this one will try to authenticate the user through a session. So, if you do not need that in some controllers or actions, you will have to expressly declare an exception through skip_before_action.

Current Class

Here, the first thing to spot is the reliance on ActiveSupport::CurrentAttributes:

Ruby
class Current < ActiveSupport::CurrentAttributes attribute :session attribute :user_agent, :ip_address delegate :user, to: :session, allow_nil: true end

This is an:

Abstract super class that provides a thread-isolated attributes singleton, which resets automatically before and after each request. This allows you to keep all the per-request attributes easily available to the whole system.

Source: Ruby on Rails API docs

Note the delegate definition too: that will tie up nicely to the Session model and its association to the User one.

Session Model

A Session model is a lovely way to handle many things related to a current request's session and its associated data, including a user.

Ruby
class Session < ApplicationRecord belongs_to :user before_create do self.user_agent = Current.user_agent self.ip_address = Current.ip_address end end

User Model

The User model is also fairly standard and uses plenty of good practices, from has_secure_password to token generators, email, and password validations.

Ruby
class User < ApplicationRecord has_secure_password # ... validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :password, allow_nil: true, length: { minimum: 12 } normalizes :email, with: -> { _1.strip.downcase } before_validation if: :email_changed?, on: :update do self.verified = false end end

Note how the email is validated, formatted properly, and the before_validation hook used to ensure that the email is marked as not verified.

Ruby
after_update if: :password_digest_previously_changed? do sessions.where.not(id: Current.session).delete_all end end

Finally, note how sessions are destroyed whenever the password changes.

Sessions Controller

The SessionsController is a fairly standard approach, reusing the Current class to facilitate finding sessions.

Ruby
class SessionsController < ApplicationController # ... def index @sessions = Current.user.sessions.order(created_at: :desc) end end

See how readable this looks:

Ruby
def create if user = User.authenticate_by(email: params[:email], password: params[:password]) @session = user.sessions.create! cookies.signed.permanent[:session_token] = { value: @session.id, httponly: true } redirect_to root_path, notice: "Signed in successfully" else redirect_to sign_in_path(email_hint: params[:email]), alert: "That email or password is incorrect" end end

This one makes great use of Ruby on Rails features to make the session creation very readable, while taking care of setting all the right information in the session (thanks to an Active Record association between the User and Session).

Next up, we'll look at identity controllers.

Identity Controllers

Three controllers have been added in the identity namespace. One handles both sending and verifying the verification email, one handles the password resets (also sending the email and managing the password update itself), and one holds the email address update for a user.

That code is concise and clear, so it's perfect to tweak for your needs. Remember to keep an eye on Authentication Zero's changelog for security issues and other significant updates. You are responsible for porting any security fixes or significant bug fixes to your version of the code generated two weeks, three months, or five years from now. And this is not just a matter of stapling Dependabot onto the repository: you might need to regenerate the code. Here's a trick to avoid conflicts between your custom additions and the generated code: use your own modules alongside the generated code. By relying on this mechanism, you will keep the code clearer and limit any issues when updating it.

The generated views are relatively simple and might not match the CSS engine you have selected (it didn't match the Tailwind one at the time of this post). But that does not matter. The views are the visible side of the iceberg. You can use your favorite Tailwind or Bootstrap template on top of the generated controllers and reuse the blueprint of the generated views for guidance.

Beyond the Base Features

Now that we have seen a primary use case of Authentication Zero and how it works, we can check more advanced features. Here is a short list of a few that are particularly interesting.

Pwned

With so many database leaks and bad password strategies, more than simple validations might be needed. The pwned gem allows you to check any password against the Pwned Password API. Adding this to a Ruby on Rails application is simple (add the gem and a validator on the password field). To add it automatically to your application with Authentication Zero, just add the --pwned flag when using the generator.

This will generate a User model with an additional validator using the Pwned gem:

Ruby
validates :password, not_pwned: { message: "might easily be guessed" }

Two-factor Authentication

Two-factor authentication (2FA) is all the rage nowadays and should be mandatory for many applications. Authentication Zero proposes two ways to handle this:

  • Two-factor authentication (with an authenticator app) and recovery codes (--two-factor)
  • Two-factor authentication using a hardware security key (--webauthn)

Both will generate additional code for the User model, its migration, and controllers and views to handle the different factors.

Password Protect Significant Changes

In many applications, requesting an additional password check before accessing or changing data is legitimate. The generator's --sudo option integrates a require_sudo before the action filter that you can use in controllers to enforce this requirement.

User Event Tracking

The --trackable option ensures that actions (such as logins and logouts) are traced so that you can display a log for security purposes.

This feature relies on an Event model:

Ruby
class Event < ApplicationRecord belongs_to :user before_create do self.user_agent = Current.user_agent self.ip_address = Current.ip_address end end

As you can see, this model is associated with a user and uses the Current class (covered earlier in this post) through a before_create hook. With just this and by relying on similar hooks in the User and Session model, events such as logins (when a Session is created), logouts (when a Session is destroyed), email verifications or changed passwords will be tracked in the database.

The following are tracked:

  • User agent
  • IP address
  • Action
  • Timestamp
  • User (since each event is attached to a user)

This feature is primarily for security purposes, providing an audit trail for critical actions related to the security or identity of a user account. The generator also generates a controller and view for a user to see the events. There is no integrated feature to purge it but it'd not be complicated to add one.

Password-less Authentication

Authentication Zero's --passwordless option allows your application to email a magic link to a user so they can sign in to a freshly prepared session.

"Sign-in as" Button

One feature that kept being requested was the ability to masquerade as a user to check how a product looks from their perspective or help them with specific updates. Authentication Zero supports this through its --masqueradable option.

Let's finally take a quick look at how Authentication Zero compares to Devise and Auth0.

Authentication Zero Compared to Devise and Auth0

As mentioned in the introduction to this post, Devise is very popular in the Ruby on Rails ecosystem. Authentication Zero presents a significant interest to many: you get your authentication layer with the knowledge that it relies on good practices. Still, once generated and integrated into your code, any additions might need some complex gymnastics. Adding 2FA two years into a product's lifetime might be tricky. With Devise and Omniauth, it's mostly a matter of configuration.

In Auth0, your application has a very different approach, with almost no authentication layer. Instead, all authentication is done by Auth0, a third-party authentication provider. Integrating key security features such as 2FA involves activating them in Auth0 settings.

Wrapping Up

Authentication Zero can be a great option for your Ruby application, but it comes with a large bag of homework for you and your team:

  • Customization of UI (granted, that's also the case for Devise)
  • Backporting any updates that would come later in Authentication Zero
  • Manual integration of additional features once the code has been integrated

When integrating such vital elements, measure your team's needs and abilities. At the very least, Authentication Zero is an example of elegant Ruby and Ruby on Rails code. The fact that it integrates significant features of modern authentication layers (such as 2FA, passwordless, logs, sudo mode, and masquerading) makes it a promising solution for a team that doesn't want to add complexity from a large library, such as Devise.

Happy coding!

Wondering what you can do next?

Finished this article? Here are a few more things you can do:

  • Share this article on social media
Thomas Riboulet

Thomas Riboulet

Our guest author Thomas is a Consultant Backend and Cloud Infrastructure Engineer based in France. For over 13 years, he has worked with startups and companies to scale their teams, products, and infrastructure. He has also been published several times in France's GNU/Linux magazine and on his blog.

All articles by Thomas Riboulet

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps