One of the weakest points in your system can easily be end users credentials. It’s easy to forget that most people don’t enable 2FA, use a password manager or even have a reasonable length of password to begin with.
Instead of mandating that passwords should be a certain length and have 3 special characters, what if we just removed the need for passwords entirely?
In this tutorial I’ll show you exactly how I have accomplished password-less accounts in Rails, using one-time passcodes and email.
How does it work?
The basic flow for logins is as follows:
- The user types their email address
- A one-time password is emailed to them
- Typing the OTP into the browser then logs them in
For signups this differs slightly. When you submit an email that has no account, the page will reload and ask you for a first and last name, then submitting the form will create your account and send you a OTP to login with.
Benefits
No longer worrying about password security
Users can’t have insecure or weak passwords, because they don’t have a password to begin with! There is also no need for password resets, changing passwords and all the notifications and emails that go along with them.
Emails are verified as standard
No need to verify your email address, If a user gets the code and types it in, their email is verified.
For Nine we wanted to make sure potential customers emails are verified before creating orders and sending them to Stripe checkout.
Signup flow is much faster
Without needing to fill in a password and a password confirmation, the account creation form can be drastically simplified. This is much better UX, especially where commerce is concerned.
Why not use a third-party service?
There are plenty of third part auth services out there, magic.link being the one I have seen get the most attention.
For my personal experience, I never like relying on third parties for such a crucial part of my system.
I know, I know, rolling your own auth is a terrible idea and if I where building a password system I would use a library like Devise. If anyone has any security concerns or thoughts on my approach please reply and let me know, I would love to discuss it further!
Building it
For those interested, I’ll show you all the relevant code, if you have further questions please ask in the comments.
Dependencies
To rely on secure OTPs we need a couple of dependencies in our Gemfile:
# One time passwords
gem "rotp"
gem "base32"
app/models/user.rb
Your user should have the following database fields at a minimum.
create_table :users do |t|
t.string "email", null: false
t.string "first_name", null: false
t.string "last_name", null: false
t.string "auth_secret", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index(:users, :email, unique: true)
Next up, we need to add a few methods to the User model for generating and verifying OTPs.
class User < ApplicationRecord
before_create :generate_auth_secret
validates :email, email: true, presence: true
validates :first_name, :last_name, presence: true
def self.generate_auth_salt
ROTP::Base32.random(16)
end
def auth_code(salt)
totp(salt).now
end
def valid_auth_code?(salt, code)
# 5mins validity
totp(salt).verify(code, drift_behind: 300).present?
end
private
# This is used as a secret for this user to
# generate their OTPs, keep it private.
def generate_auth_secret
self.auth_secret = ROTP::Base32.random(16)
end
def totp(salt)
ROTP::TOTP.new(auth_secret + salt, issuer: "YourAppName")
end
end
Note the salt is stored in a cookie and ensures the user can only login from the same web browser that they requested the login from. This means that if someone looked over their shoulder and got their auth code, they couldn’t login on a different web browser.
UserLogin service
This service handles the business logic for dealing with requesting a code and verifying it was correct and it will keep our controllers tidy.
module UserLogin
module_function
# Called when a user first types their email address
# requesting to login or sign up.
def start_auth(params)
# Generate the salt for this login, it will later
# be stored in rails session.
salt = User.generate_auth_salt
user = User.find_by(email: params.fetch(:email).downcase.strip)
if user.nil?
# User is registering a new account
user = User.create!(params)
end
# Email the user their 6 digit code
AuthMailer.auth_code(user, user.auth_code(salt)).deliver_now
salt
end
# Called to check the code the user types
# in and make sure it’s valid.
def verify(email, auth_code, salt)
user = User.find_by(email: email)
if user.blank?
return UserLoginResponse.new(
"Oh dear, we could not find an account using that email.
Contact support@nine.shopping if this issue persists."
)
end
unless user.valid_auth_code?(salt, auth_code)
return UserLoginResponse.new("That code’s not right, better luck next time 😬")
end
UserLoginResponse.new(nil, user)
end
UserLoginResponse = Struct.new(:error, :user)
end
Controllers and routes
Firstly we need an Authenticatable concern that will provide methods like current_user
and user_signed_in?
. You will also need to include Authenticatable
inside your application_controller.rb
file.
# app/controllers/concerns/authenticatable.rb
module Authenticatable
extend ActiveSupport::Concern
def authenticate_user!
redirect_to auth_path unless current_user
end
def user_signed_in?
current_user.present?
end
def current_user
@current_user ||= lookup_user_by_cookie
end
def lookup_user_by_cookie
User.find(session[:user_id]) if session[:user_id]
end
end
Add the follow to your config/routes.rb
file.
resource :auth, only: %i[show create destroy], controller: :auth
resource :auth_verifications, only: %i[show create]
We need two controllers to make this work, AuthController handles requesting auth and logging out, whereas AuthVerificationsController handles checking the OTP was correct.
# app/controllers/auth_controller.rb
class AuthController < ApplicationController
skip_before_action :authenticate_user!, except: :destroy
def show; end
def create
session[:email] = params[:email]
session[:salt] = UserLogin.start_auth(params.permit(:email, :first_name, :last_name))
redirect_to auth_verifications_path
rescue ActiveRecord::RecordInvalid
# If the user creations fails (usually when first and last name are empty)
# we reload the form, and also display the first and last name fields.
@display_name_fields = true
render :show
end
def destroy
session.delete(:user_id)
redirect_to auth_path, notice: "You are signed out"
end
end
# app/controllers/auth_verifications_controller.rb
class AuthVerificationsController < ApplicationController
skip_before_action :authenticate_user!
def show
@email = session[:email]
render "auth/verify"
end
def create
@email = session[:email]
resp = UserLogin.verify(@email, params[:auth_code], session[:salt])
if resp.error
flash[:error] = resp.error
render "auth/verify"
else
session.delete(:email)
session.delete(:salt)
session[:user_id] = resp.user.id
redirect_to root_path, notice: "You are now signed in"
end
end
end
Views
In these views I am using tailwind CSS, feel free to style them however you want.
<%# app/views/auth/show.html.erb %>
<p class="text-2xl text-gray-900 font-medium mb-3">
What’s your email?
</p>
<%= form_with(url: auth_path, html: { data: { turbo: false } }) do |f| %>
<%= f.email_field :email, value: params[:email], placeholder: "you@email.com", class: "w-full rounded-md border-gray-300" %>
<% if @display_name_fields %>
<%= f.text_field :first_name, placeholder: "First name", class: "mt-3 w-full rounded-md border-gray-300" %>
<%= f.text_field :last_name, placeholder: "Last name", class: "mt-3 w-full rounded-md border-gray-300" %>
<% end %>
<%= f.submit "Continue", class: "w-full cursor-pointer flex relative justify-center items-center py-2 px-4 rounded-md font-medium leading-6 focus:outline-none transition-colors duration-150 ease-in-out border border-transparent text-white bg-pink-600 hover:bg-pink-500 focus:border-pink-300 focus:shadow-outline-gray mt-3" %>
<div class="mt-3 text-center text-gray-600 text-sm">By continuing you agree to our <a href="https://nine.shopping/terms" target="_blank" rel="noopener noreferrer" class="underline text-pink-500">Terms of Use</a></div>
<% end %>
<%# app/views/auth/verify.html.erb %>
<div class="leading-relaxed text-lg text-gray-600">
We just emailed you a six digit code, please enter it in the box below.
</div>
<%= form_with(url: auth_verifications_path, html: { class: "mt-6" }) do |f| %>
<%= f.label :email, class: "flex items-center justify-between text-sm font-medium text-gray-700 mb-1" do %>
Email
<%= link_to "Change", auth_path, class: "text-gray-500 underline font-normal" %>
<% end %>
<%= f.email_field :email, value: @email, placeholder: "you@email.com", class: "w-full rounded-md border-gray-300 bg-gray-100", disabled: true %>
<%= f.label :email, class: "flex items-center justify-between text-sm font-medium text-gray-700 mb-1 mt-3" do %>
Auth code
<%= link_to "Re-send code", auth_path(email: @email), method: :post, class: "text-gray-500 underline font-normal" %>
<% end %>
<%= f.text_field :auth_code, class: "w-full rounded-md border-gray-300 text-2xl tracking-widest text-center", maxlength: 6 %>
<%= f.submit "Continue to your account", class: "w-full cursor-pointer flex relative justify-center items-center py-2 px-4 rounded-md font-medium leading-6 focus:outline-none transition-colors duration-150 ease-in-out border border-transparent text-white bg-pink-600 hover:bg-pink-500 focus:border-pink-300 focus:shadow-outline-gray mt-3" %>
<% end %>
Mailer
The final piece of the puzzle is hooking up the mailer to send out your OTPs.
class AuthMailer < ApplicationMailer
def auth_code(user, auth_code)
@user = user
@auth_code = auth_code
mail to: @user.email, subject: "Hey #{@user.first_name}, use this auth code to sign in"
end
end
<h1>Hey <%= @user.first_name %>,</h1>
<p>Use the six digit code below to continue signing in to your account (this will expire in 5 minutes).</p>
<table class="attributes" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="attributes_content">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="attributes_item">
<span style="display: block; font-size: 35px; font-weight: bold; letter-spacing: 10px; text-align: center;"><%= @auth_code %></span>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>If you didn't request this code you can safely ignore this email.</p>
Top comments (14)
Hi Pete, very interesting approach !
One thing I would tweak though is I would keep the same auth flow for existing and new users. In the case of new users I would force them to enter their extra info after auth.
The rationale here is that I wouldn't want anyone guessing if an email is already signed up to my system.
Hi Pete! Great post you published there.
I like the idea that maintaining password is hard, that even people are monetizing it by building a password manager.
But, instead of otp that sent to the email, wdyt of Using MFA directly? so the input would be email+MFA.
What I think is, that would save us developer, a mail service cost.
Hi Pete! Thanks for the article, it was a really interesting read!
May I ask you a question? Since when using this approach the user has no password at all, how could you protect some critical settings of the app? For example, in my app if you want to change the MFA method, delete sessions opened in other browsers or close your account you must enter your password to be sure it's you the one requesting the action.
But if the user has no password, how could we achieve this? The only way I can think of would be sending an email to the user with a link to confirm the action, but that seems a bit risky to me.
How do you think this could be done? Thanks!
Koas, that’s a great thought! For maximum security I haven’t thought of how to also do MFA with this approach. I guess you could also use an authenticator app or SMS alongside the OTPs through email. It just seems strange typing two 6 digit codes in, but I guess that’s fine.
I would send them another OTP via email and show a modal that makes them enter it. Since the salt is different every time OTP is used, there's nothing wrong imo with sending more of these codes to the email address.
I'd be interested in knowing why you think sending an email seems risky? Given that a password can be reset through email, surely traditional passwords carry the same risk?
Hi! Thanks for your reply! The modal asking for the OTP is a nice solution, much easier than what I thought.
About the "risky" part: I use the idea of entering your password to change critical settings to avoid this situation:
I'm working at my PC, and leave for the bathroom. I have my app session open.
The bad guy reaches me PC and access my app settings, removing my MFA setup or even closing my account.
This is of course a really edge case, but I usually am quite paranoid, so I want to do my best to avoid this. If my password is required to change some critical settings then the bad guy can't do it.
If the OTP is sent to me via email and, still considering this edge case, I have my email client open then the bad guy can easily get the code and do nasty things.
This is why I consider the email method a bit "risky". It wouldn't protect our account if the bad guy gains access to our computer while we're away. Please note the quotes, I know this is really an extreme case, but you never can be too cautious regarding security.
I know you may think "If the bad guy has access to my computer surely he'll be able to log out, request a new password via the "Forgot your password?" and then do all the bad things". That is absolutely true, but this is where the login and password change Telegram notifications come to our rescue:
When someone enters your account you can be notified via Telegram (the web app bot sends you a message). The same happens when your password changes.
If you are in the loo of course you have your phone with you (xD), so you get the message and instantly know something's wrong. The bot has an "Under attack" command that closes any open session on any device, changes your password to a random one and sends it to you. Now the bad guy can't access your account with the restored password, and must begin the "Forgot your password" process again (which you can respond in the same way).
Of course, these notifications can be turned off and you can choose not to connect to the Telegram app bot, but for security paranoids like me these measures add an extra layer of security to the app.
As I'm writing this (rubber duck mode enabled) I'm thinking thay maybe a nice way to avoid the bad guy resetting your password would be asking for the MFA code (if enabled) before restoring it. That would prevent anyone that can access our email to reset our password unless they had access to our phone too.
Sorry for the long message! I'd like to know what do you think of this method, am I pushing it too far? Do you think it's worth it? Any other ideas on this subject would be greatly appreciated!
A quick answer to myself:
There would be no need to ask for the MFA code when resetting the password: if MFA is enabled the bad guy won't be able to access the account even with the correct password.
This is interesting, and perhaps a little bit extreme for the use case and type of user I'm using it for, but I do have an idea that’s kinda like MFA and would potentially solve this for you.
If you made the user create a 4 digit pin code when signing up. This pin code is then what's requested when you go to take these sensitive actions within the app. The UX of it would be quite nice as well, it would be a lot faster to type in a 4 digit pin from memory than to lookup a password or get a code from email.
I'm not quite sure how you would handle resetting your pin if you forget it though.
Hope that helps!
The pin code is a great idea, that's what my bank uses for signing operations inside their app. You have username and password to access your account, where you can see your balance and movements but if you want to do something sensitive like transfer funds to another account you have to enter some digits of an 8 digit pin (for example, digits 1, 5, 6 and 8, changes every time).
Thanks for your replies!
Hello Pete, Great post you published there
I have some old people, and they have some problems to connect to their account.
If the email take a long time to arrive in their emails box, they generate a new one, then another and another new code (by the auth page).
A solution it's to add a redirect_to if session[:email].present? in the AuthController :
Is it a good idea ?
But I think, it will better, if a auth_code is always valid don't generate a new code but resend the same.
How can I do that ?
Hey @tioneb12
You would need to make changes to the UserLogin#start_auth method, that it either takes in a salt, or generates one. Currently it generates a new salt every time start_auth is called. So you'd want to first check for the existance of the salt in the session. Also after a successful login, you probably want to clear the salt.
Hope that helps!
Pete
I hope this proves useful to anyone thinking of password-less authentication. If it gets much attention I might consider wrapping it up in a gem to make the install process even easier.
I would love to discuss all aspects of this further, so if you have any UX, security or general thoughts please ask me anything!
Wouldn't emailing a link with a JWT token encoded with a private certificate be more secure?
That’s interesting Shane, I would say if you also ensured there was a unique salt stored in the browser session, then yes. Although this poses a UX concern, what if the end user requests the magic link on their desktop, but opens the email and taps 'login' on their phone? Then this approach doesn’t work, or you need to get rid of the salt, which IMO adds another layer of security, ensuring you can only login from the browser that requested the login.
The only thing you are getting from the JWT approach is a longer token. Both approaches assume email is a secure protocol, but then so does every app that implements password resets.
I don’t think either approach is necessarily bad or weak, but would like to be challenged on that!
hey man, just read your article on passwordless auth in rails and learned a lot - thanks so much! In the beginning you mention, that you don't like to rely on third parties for authentication (which I can understand). What would be a situation or requirement where you would still consider to use a third-party for authentication?