NoPassword is a toolkit that makes it easy to implement secure, passwordless authentication via email, SMS, or any other side-channel. It also includes OAuth controllers for Google and Apple sign-in.
Add this line to your Rails application's Gemfile:
$ bundle add nopasswordThen install the controllers and views:
$ bundle exec rails generate nopassword:installAdd the route to your config/routes.rb:
nopassword EmailAuthenticationsControllerRestart the development server and head to http://localhost:3000/email_authentications/new.
NoPassword uses a session-bound token approach:
- User enters their email in your app
- A 128-bit random token is generated and stored in the user's session
- A link containing the token is emailed to the user
- User clicks the link — it only works in the same browser that requested it
The token in the email is useless without the matching session. An attacker who intercepts the email would need BOTH:
- The link from the email
- The victim's session cookie
If they already have the session cookie, they already have access to the session anyway.
Most magic link gems put the entire secret in the email. Anyone with the link can authenticate from any browser.
NoPassword binds the link to the user's session — the link only works in the browser that requested it. This adds a second factor: possession of the session cookie.
NoPassword does not rate limit email sending — that's your responsibility. Use Rails' built-in rate limiting:
class EmailAuthenticationsController < NoPassword::EmailAuthenticationsController
rate_limit to: 5, within: 1.minute, only: :create, with: -> {
flash[:alert] = "Too many requests. Please wait a minute."
redirect_to url_for(action: :new)
}
endCustomize the installed controller to integrate with your user system:
class EmailAuthenticationsController < NoPassword::EmailAuthenticationsController
def verification_succeeded(email)
self.current_user = User.find_or_create_by!(email: email)
redirect_to dashboard_url
end
endOverride these methods to customize behavior:
class EmailAuthenticationsController < NoPassword::EmailAuthenticationsController
# Called when the user successfully verifies their email
def verification_succeeded(email)
redirect_to root_url
end
# Called when the link has expired
def verification_expired(verification)
flash[:alert] = "Link has expired. Please try again."
redirect_to url_for(action: :new)
end
# Called when the token is invalid
def verification_failed(verification)
flash.now[:alert] = verification.errors.full_messages.to_sentence
render :show, status: :unprocessable_entity
end
# Called when the link is opened in a different browser
def verification_different_browser(verification)
flash.now[:alert] = "Please open this link in the browser where you requested it."
render :show, status: :unprocessable_entity
end
# Customize how the email is sent
def deliver_challenge(challenge)
EmailAuthenticationMailer
.with(email: challenge.email, url: show_url(challenge.token))
.authentication_email
.deliver_later
end
# Default URL to redirect to after authentication
def after_authentication_url
root_url
end
endWhen a user opens the link in a different browser (e.g., email app's webview), the verification will fail because there's no matching session. Override the verification_different_browser hook to customize this behavior:
class EmailAuthenticationsController < NoPassword::EmailAuthenticationsController
def verification_different_browser(verification)
# Show a page explaining they need to copy the link to their original browser
render :different_browser
end
endThe generator gives you views you can customize. If you need full control over the controller too, include the concern directly:
class SessionsController < ApplicationController
include NoPassword::EmailAuthentication
def verification_succeeded(email)
self.current_user = User.find_or_create_by!(email: email)
redirect_to dashboard_url
end
endThen use nopassword with your controller — the routes come with the concern:
# config/routes.rb
nopassword SessionsController # generates /sessions routesThe routes are derived from your controller name. To customize the path:
# config/routes.rb
nopassword SessionsController, path: "login" # generates /login routesOr skip the concern entirely and use the models directly with your own views and routes:
class SessionsController < ApplicationController
def new
@authentication = NoPassword::Email::Authentication.new(session)
end
def create
@authentication = NoPassword::Email::Authentication.new(session)
@authentication.email = params[:email]
if @authentication.valid? && @authentication.challenge.save
@authentication.save
# Send your own email
SessionMailer.with(url: verify_url(@authentication.challenge.token)).deliver_later
redirect_to :check_email
else
render :new, status: :unprocessable_entity
end
end
def show
@authentication = NoPassword::Email::Authentication.new(session)
@verification = @authentication.verification(token: params[:id])
end
def update
@authentication = NoPassword::Email::Authentication.new(session)
@verification = @authentication.verification(token: params[:id])
if @verification.verify
self.current_user = User.find_or_create_by!(email: @authentication.email)
@authentication.delete
redirect_to dashboard_url
else
render :show, status: :unprocessable_entity
end
end
endNoPassword is organized into composable modules:
NoPassword
├── Link # Token challenge/verification
│ ├── Base # Session storage mechanics
│ ├── Challenge # Generates token, stores identifier, TTL
│ └── Verification # Validates token, checks expiration
├── Session # Controller helpers for session management
│ └── Authentication # Stores return_url, wraps Link
├── Email # Email-specific implementation
│ ├── Authentication # Adds email validation
│ ├── Challenge # Aliases identifier as email
│ └── Mailer # ActionMailer for sending links
├── EmailAuthentication # Controller concern with all actions
├── EmailAuthenticationsController # Ready-to-use controller
└── OAuth
├── GoogleAuthorizationsController
└── AppleAuthorizationsController
The Link module is channel-agnostic. To add SMS support:
class SmsAuthentication < NoPassword::Session::Authentication
attribute :phone, :string
validates :phone, presence: true, format: { with: /\A\+?[1-9]\d{1,14}\z/ }
def identifier
phone
end
endNoPassword includes OAuth controllers for Google and Apple. Create a controller that inherits from the OAuth controller:
# app/controllers/google_authorizations_controller.rb
class GoogleAuthorizationsController < NoPassword::OAuth::GoogleAuthorizationsController
def self.credentials = Rails.application.credentials.google
def self.client_id = credentials.client_id
def self.client_secret = credentials.client_secret
protected
def authorization_succeeded(sso)
user = User.find_or_create_by(email: sso.fetch("email"))
user.update!(name: sso.fetch("name"))
self.current_user = user
redirect_to root_url
end
def authorization_failed
redirect_to login_path, alert: "OAuth authorization failed"
end
endOr with environment variables:
class GoogleAuthorizationsController < NoPassword::OAuth::GoogleAuthorizationsController
def self.client_id = ENV["GOOGLE_CLIENT_ID"]
def self.client_secret = ENV["GOOGLE_CLIENT_SECRET"]
# ...
endAdd the route:
# ./config/routes.rb
nopassword GoogleAuthorizationsControllerCreate a sign-in button:
<%= form_tag google_authorization_path, data: { turbo: false } do %>
<%= submit_tag "Sign in with Google" %>
<% end %>Passwords are a pain:
- People choose weak passwords - Complexity requirements make them hard to remember
- People forget passwords - Password reset flows use email anyway
- Password fatigue - Users appreciate not having to create yet another password
If you'd like to contribute, start a discussion at https://github.com/rocketshipio/nopassword/discussions/categories/ideas.
The gem is available as open source under the terms of the MIT License.