March 14, 2022
•Last updated November 5, 2023
Passwordless login with Rails 7
Ruby on Rails ships with no user authentication layer. The core maintainers of the framework made this decision as it could vary per app how you might want to handle such a feat.
Most developers I come across reach for the devise gem that works very well for most of your needs. There are even additional gems offering newer features like inviting users, one-time passwords, and more.
Recently I questioned the whole notion of a login and password mechanism. We are conditioned to this way of "access" but it's quite cumbersome when it comes down to it.
Users are forced to remember their login and password credentials which leads to them choosing something they can remember. Sadly, this removes a lot of security features from the whole point of a secret login and password.
Knowing there was probably a pre-made solution out there I did some searching. I found a gem called passwordless which seems to answer the calling I was on the hunt for.
Much of the gem could probably be accomplished without the additional dependency but it includes some handy features.
Let's take a tour...
Generating an app
I'll generate a fresh Rails 7 application and pass a few preferences I prefer for CSS and JavaScript. We really won't be using these too much in this guide but I wanted to bundle it just in case.
rails new passwordless_app -c tailwind -j esbuild
After cd
-ing into the app folder I'll generate a new User
model.
rails g model User email username
Adding the passwordless gem
Inside your Gemfile
add the passwordless
gem to the end.
# Gemfile
gem 'passwordless'
Then run the following three commands to install necessary dependencies and migrations.
bundle install
rails passwordless:install:migrations
rails db:migrate
Inside the migration file you'll see a new table with a series of new columns present:
# db/migrations/....
class CreatePasswordlessSessions < ActiveRecord::Migration[5.1]
def change
create_table :passwordless_sessions do |t|
t.belongs_to(
:authenticatable,
polymorphic: true,
index: {name: "authenticatable"}
)
t.datetime :timeout_at, null: false
t.datetime :expires_at, null: false
t.datetime :claimed_at
t.text :user_agent, null: false
t.string :remote_addr, null: false
t.string :token, null: false
t.timestamps
end
end
end
The migration creates a polymorphic association called "authenticatable" which means you can add this to any model. User
is quite common but maybe your app has a Subscriber
or a Member
for example.
Additionally, there are columns for timeouts, expirations, tokens, and more. This helps allow persistent logins without all the normal cookie-dependent stuff.
Specify which field on the user to use
On the user model, we need to give the passwordless gem a field to target. In this case, I'll use the email column we added in a previous step.
# app/models/user.rb
class User < ApplicationRecord
validates :email, presence: true, uniqueness: { case_sensitive: false }
passwordless_with :email
end
Because we defined passwordless_with in the user model we can then mount the bundled engine to match.
# config/routes.rb
Rails.application.routes.draw do
passwordless_for :users
# other routing...
end
Current user helper methods
The passwordless gem doesn't give you the current_user
helper automatically but luckily it's not a chore to add. Inside our application_controller.rb
file we need to extend the app a touch.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Passwordless::ControllerHelpers
helper_method :current_user
private
def current_user
@current_user ||= authenticate_by_session(User)
end
def require_user!
return if current_user
redirect_to users.sign_in_path, alert: 'Please sign in to view this content'
end
end
We include a ControllerHelpers
module from the Passwordless
module/engine. In doing so we get the method called authenticate_by_session
. That extracts some logic from our app and is powered by the engine from the gem.
Two private methods are added to find the current user and check if the current user is authenticated.
So now in other controllers, we can use this method in a callback function.
Example authentication on a static controller
Let's make a fictional controller for the sake of example. Maybe there's an action on it we want to require people to authenticate for.
rails g controller static index members_only --skip-routes --skip-assets --skip-helpers
create app/controllers/static_controller.rb
invoke erb
create app/views/static
create app/views/static/index.html.erb
create app/views/static/members_only.html.erb
invoke test_unit
create test/controllers/static_controller_test.rb
invoke helper
create app/helpers/static_helper.rb
invoke test_unit
I'll generate a StaticController
with an index
and members_only
action skipping any automatic stuff we don't really need.
Setting a default root route is probably ideal
Rails.application.routes.draw do
passwordless_for :users
root to:"static#index" # add this line
end
Now we can boot up the server:
bin/dev
I'll add some markup to make the page a bit more appealing.
<!-- app/views/static/index.html.erb-->
<div class="max-w-2xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
<h1 class="font-black text-3xl">Super-mega Passwordless App</h1>
<p class="text-lg text-gray-600 my-4">Sign up for members-only access</p>
<div class="flex items-center space-x-3">
<%= link_to "Sign in", users.sign_in_path, class: "bg-sky-500 text-white
px-3 py-2 font-semibold" %> <%= link_to "Create account", new_user_path,
class: "bg-sky-500 text-white px-3 py-2 font-semibold" %>
</div>
</div>
Note the sign-in path now users users.sign_in_path
. If you recall how we define our routing this is now required to access these defined paths within the gem's engine. Head to localhost:3000/rails/info/routes
and look for the engine's routes to see all that comes bundled.
Unfortunately, this won't render correctly at the moment because we are missing some additional routing and controller logic for our User resource. I'll add that next.
Users configuration
Create a new users controller in app/controllers/
Inside it, we'll add the following action.
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
@user = User.new
end
def create
@user = User.new user_params
if @user.save
sign_in @user
redirect_to root_path, flash: { notice: 'Welcome aboard!' }
else
render :new
end
end
private
def user_params
params.require(:user).permit(:username, :email)
end
end
Now we need some routing
Rails.application.routes.draw do
passwordless_for :users
resources :users # add this line
root to:"static#index"
end
Finally, we need a new form and view. I'll create two files. One is new.html.erb
and the other is _form.html.erb
. Those will live inside app/views/users/
<!-- app/views/users/new.html.erb -->
<div class="max-w-xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
<h1 class="font-black text-2xl mb-6">Create an account</h1>
<%= render "form", user: @user %>
</div>
Here's the form.
<!-- app/views/users/_form.html.erb -->
<%= form_with(model: user, data: {turbo: false}) do |form| %>
<div class="mb-6">
<%= form.label :username, class: "block text-left mb-2" %> <%= form.text_field
:username, class: "block w-full px-3 py-2 focus:ring-sky-100 ring-4 border
ring-transparent focus:outline-none rounded" %>
</div>
<div class="mb-6">
<%= form.label :email, class: "block text-left mb-2" %> <%= form.email_field
:email, class: "block w-full px-3 py-2 focus:ring-sky-100 ring-4 border
ring-transparent focus:outline-none rounded" %>
</div>
<%= form.submit "Create account", class: "bg-emerald-500 font-medium px-3 py-2
text-white w-full text-center hover:bg-emerald-600 cursor-pointer rounded" %> <%
end %>
At this point, you should be able to create a new account and successfully sign in. Since our app is extremely lean right now it's tough to know that we are indeed authenticated.
I'll update the static/index.html.erb
view to check the state.
<!-- app/views/static/index.html.erb-->
<div class="max-w-2xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
<h1 class="font-black text-3xl">Super-mega Passwordless App</h1>
<% if current_user.present? %>
<p class="py-3">
You are signed in as
<span class="font-semibold text-sky-800"
><%= current_user.username %>(<%= current_user.email %>)</span
>
</p>
<%= link_to "Sign out", users.sign_out_path, class: "bg-sky-500 text-white
px-3 py-2 font-semibold rounded" %> <% else %>
<p class="text-lg text-gray-600 my-4">Sign up for members-only access</p>
<div class="flex items-center space-x-3 justify-center">
<%= link_to "Sign in", users.sign_in_path, class: "bg-sky-500 text-white
px-3 py-2 font-semibold rounded" %> <%= link_to "Create account",
new_user_path, class: "bg-emerald-500 text-white px-3 py-2 font-semibold
rounded" %>
</div>
<% end %>
</div>
If you sign out and click sign in again you might notice a "Send magic link" button now in view. This comes bundled with the gem but it's very primitive. Let's adjust this design by copying the gem's views and changing those.
An engine will know to use the views in our app as opposed to the gems since it's a top-down structure. So long as we make use of the same logic we can get away with just updating the design.
I created a folder structure like the following:
app/views
/passwordless
/sessions
-- create.html.erb
-- new.html.erb
We'll adjust each file...
Here's the new.html.erb
file
<!-- app/views/passwordless/sessions/new.html.erb-->
<div class="max-w-xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
<h1 class="font-black text-2xl mb-6">Sign in</h1>
<%= form_for @session, url: send(Passwordless.mounted_as).sign_in_path, data:
{turbo: false } do |f| %> <% email_field_name =
:"passwordless[#{@email_field}]" %> <%= text_field_tag email_field_name,
params.fetch(email_field_name, nil), class: "block w-full px-3 py-2
focus:ring-sky-100 ring-4 border ring-transparent focus:outline-none rounded
mb-3", placeholder: "Enter email" %> <%= f.submit
I18n.t('passwordless.sessions.new.submit'), class: "bg-sky-500 font-medium
px-3 py-2 text-white w-full text-center hover:bg-sky-600 cursor-pointer
rounded" %> <% end %>
</div>
The create.html.erb
file
<!-- app/views/passwordless/sessions/create.html.erb-->
<div class="max-w-xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
<p>
<%= I18n.t('passwordless.sessions.create.email_sent_if_record_found') %>
</p>
</div>
There is also a magic_link.text.erb
file. This can be extended a bit but it's mostly a design exercise. We'll leverage the console's logging to capture the unique magic link we'll need all the same.
If you wanted to copy that template over and adjust the design feel free!
Authenticating with a magic link
Now that we have the updated views we can give a "Sign in" a shot. From the root path click "Sign in". That should redirect you to a form where you enter your email.
After entering the email you signed up with click the "Send magic link" button then head to your console to see the logs.
Bugs
You might get an error at this point on a fresh rails app.
This is because of a default_url_options method we need to define locally. This is responsible for emailing logic that Rails taps into for the mailer side of the framework. Ultimately, in production, you would tap into some sort of email delivery service. Their guides would give you the URL you need (usually your app's main domain).
In our case, since everything is in a local environment we need to tell the app as such.
#config/environments/development.rb
...
Rails.application.configure do
# a bunch of configs
config.action_mailer.default_url_options = {host: "localhost", port: 3000}
end
This line defines essentially what our rails app server is running on locally. By default that is localhost:3000.
Try the "Send magic link" form again and check your logs. You should see something like this:
Started POST "/users/sign_in" for 127.0.0.1 at 2022-03-14 10:41:21 -0500
10:41:21 web.1 | Processing by Passwordless::SessionsController#create as HTML
10:41:21 web.1 | Parameters: {"authenticity_token"=>"[FILTERED]", "passwordless"=>"[FILTERED]", "commit"=>"Send magic link", "authenticatable"=>"user"}
10:41:21 web.1 | User Load (0.3ms) SELECT "users".* FROM "users" WHERE (lower(email) = '[email protected]') ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
10:41:21 web.1 | TRANSACTION (0.1ms) begin transaction
10:41:21 web.1 | Passwordless::Session Load (0.2ms) SELECT "passwordless_sessions".* FROM "passwordless_sessions" WHERE "passwordless_sessions"."token" = ? LIMIT ? [["token", "[FILTERED]"], ["LIMIT", 1]]
10:41:21 web.1 | Passwordless::Session Create (0.7ms) INSERT INTO "passwordless_sessions" ("authenticatable_type", "authenticatable_id", "timeout_at", "expires_at", "claimed_at", "user_agent", "remote_addr", "token", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["authenticatable_type", "User"], ["authenticatable_id", 1], ["timeout_at", "2022-03-14 16:41:21.288739"], ["expires_at", "2023-03-14 15:41:21.288526"], ["claimed_at", nil], ["user_agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0"], ["remote_addr", "127.0.0.1"], ["token", "[FILTERED]"], ["created_at", "2022-03-14 15:41:21.292647"], ["updated_at", "2022-03-14 15:41:21.292647"]]
10:41:21 web.1 | TRANSACTION (0.9ms) commit transaction
10:41:21 web.1 | Rendering /Users/<username>/.rbenv/versions/3.0.3/lib/ruby/gems/3.0.0/gems/passwordless-0.10.0/app/views/passwordless/mailer/magic_link.text.erb
10:41:21 web.1 | Rendered /Users/<username>/.rbenv/versions/3.0.3/lib/ruby/gems/3.0.0/gems/passwordless-0.10.0/app/views/passwordless/mailer/magic_link.text.erb (Duration: 0.8ms | Allocations: 216)
10:41:21 web.1 | Passwordless::Mailer#magic_link: processed outbound mail in 4.3ms
10:41:21 web.1 | Delivered mail 622f622149fc5_11c243c8c817c@system-mail (2.2ms)
10:41:21 web.1 | Date: Mon, 14 Mar 2022 10:41:21 -0500
10:41:21 web.1 | From: [email protected]
10:41:21 web.1 | To: [email protected]
10:41:21 web.1 | Message-ID: <622f622149fc5_11c243c8c817c@system-mail>
10:41:21 web.1 | Subject: =?UTF-8?Q?Your_magic_link_=E2=9C=A8?=
10:41:21 web.1 | Mime-Version: 1.0
10:41:21 web.1 | Content-Type: text/plain;
10:41:21 web.1 | charset=UTF-8
10:41:21 web.1 | Content-Transfer-Encoding: 7bit
10:41:21 web.1 |
10:41:21 web.1 | Here's your link: http://localhost:3000/users/sign_in/dkdsZDQ4HP3v_T1V1kgRSlCy-xfD3DeE4JlRKvvidjg
10:41:21 web.1 |
10:41:21 web.1 | Rendering layout layouts/application.html.erb
10:41:21 web.1 | Rendering passwordless/sessions/create.html.erb within layouts/application
10:41:21 web.1 | Rendered passwordless/sessions/create.html.erb within layouts/application (Duration: 0.5ms | Allocations: 147)
10:41:21 web.1 | Rendered layout layouts/application.html.erb (Duration: 2.6ms | Allocations: 1808)
10:41:21 web.1 | Completed 200 OK in 24ms (Views: 3.4ms | ActiveRecord: 2.3ms | Allocations: 13295)
All we really need to confirm is that the email gets sent. Inside is a link to sign in.
http://localhost:3000/users/sign_in/dkdsZDQ4HP3v_T1V1kgRSlCy-xfD3DeE4JlRKvvidjg
When you visit that link, you are authenticated!
Protecting controllers
We made a members_only view on our StaticController
in a previous step. To lock this down to only authenticated users we can pass a callback function within the controller.
# app/controllers/static_controller.rb
class StaticController < ApplicationController
before_action :require_user!, only: :members_only
def index
end
def members_only
end
end
Now we need to update the routes once more.
# config/routes.rb
Rails.application.routes.draw do
passwordless_for :users
resources :users
root to:"static#index"
get "static/members_only", as: :members_only # add this line
end
Then I'll update our index
template once more with a new link for signed-in users
<!--app/views/static/index.html.erb -->
<div class="max-w-2xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
<h1 class="font-black text-3xl">Super-mega Passwordless App</h1>
<% if current_user.present? %>
<p class="py-3">
You are signed in as
<span class="font-semibold text-sky-800"
><%= current_user.username %>(<%= current_user.email %>)</span
>
</p>
<%= link_to "View members only content", members_only_path, class: "underline
block mb-6" %>
<%= link_to "Sign out", users.sign_out_path, class: "bg-sky-500
text-white px-3 py-2 font-semibold rounded" %> <% else %>
<p class="text-lg text-gray-600 my-4">Sign up for members-only access</p>
<div class="flex items-center space-x-3 justify-center">
<%= link_to "Sign in", users.sign_in_path, class: "bg-sky-500 text-white
px-3 py-2 font-semibold rounded" %> <%= link_to "Create account",
new_user_path, class: "bg-emerald-500 text-white px-3 py-2 font-semibold
rounded" %>
</div>
<% end %>
</div>
Then the members_only
page can have some dummy content for now.
<div class="max-w-2xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
<h1 class="font-black text-3xl mb-6">Super-mega Members-only Content</h1>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Rerum repudiandae quisquam mollitia in vero voluptas eos amet! Impedit, mollitia vel saepe voluptas tempore velit. Adipisci repellat porro eum ullam culpa.</p>
<%= link_to "Back", root_path, class: "block my-2 underline" %>
</div>
If you sign out and try to visit the page directly now (http://localhost:3000/static/members_only) it will prompt you to sign in. Perfect!
I didn't originally include the markup for our flash messages but in case you want to view the messages in action add this code to your layout/application.html.erb
template.
<!-- app/views/layouts/application.html.erb-->
<html>
<!-- ... -->
<body>
<% if notice %>
<div role="alert" class="py-3 text-white bg-blue-600 text-center">
<p><%= sanitize notice %></p>
</div>
<% end %>
<% if alert %>
<div role="alert" class="py-3 text-white bg-red-600 text-center">
<p><%= sanitize alert %></p>
</div>
<% end %>
</body>
</html>
Categories
Collection
Part of the Ruby on Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.