Magic links are great. You click a link, you’re signed in. No password to remember, no password to leak. For most sign-in flows they’re probably the ideal solution.
But there’s one situation where they fall apart, and it’s one that comes up more often than you’d think. You’re on your laptop, you hit “sign in,” and the email lands on your phone. Now you’re tapping a magic link on your phone, which signs you in on your phone, which is not where you wanted to be signed in. You wanted to be signed in on your laptop.
The fix is simple enough: give the user a second option. A short pin code they can read off their phone and type into the laptop. Same authentication token, two ways to use it.
I wanted to see how far I could get with just what Rails gives you out of the box. No Devise, no gems beyond bcrypt. Turns out, you can get pretty far.
The model
The entire system hangs off a single model: AuthenticationToken. It stores an email address, a pin code digest, and an optional expired_at timestamp. That’s it.
class AuthenticationToken < ApplicationRecord
has_secure_password :pin_code
generates_token_for :sign_in, expires_in: 15.minutes do
expired_at.presence
end
normalizes :email_address, with: ->(email_address) { email_address.strip.downcase }
validates :email_address, presence: true, format: URI::MailTo::EMAIL_REGEXP
before_validation :generate_pin_code
scope :not_expired, -> { where(expired_at: nil) }
def expire!
update!(expired_at: Time.current)
end
private
def generate_pin_code
return if pin_code.present? || pin_code_digest.present?
self.pin_code = SecureRandom.random_number(10**6).to_s.rjust(6, '0')
end
end
There are two things here that do most of the heavy lifting, and they’re both built into Rails.
has_secure_password isn’t just for passwords
Most people reach for has_secure_password when they’re building a traditional email and password login. You add password_digest to the table, include bcrypt, and Rails handles hashing and authentication for you.
What’s less well known is that you can pass it a custom attribute name. has_secure_password :pin_code tells Rails to look for a pin_code_digest column rather than password_digest. It gives you the same hashing, the same authenticate_pin_code method, and the same protection. The pin code itself is never stored in the database, only its bcrypt digest.
This means we can generate a random six-digit code, hand it to the user in an email, and then verify it later without ever storing the plaintext. The generation happens in a before_validation callback:
def generate_pin_code
return if pin_code.present? || pin_code_digest.present?
self.pin_code = SecureRandom.random_number(10**6).to_s.rjust(6, '0')
end
The rjust(6, '0') is worth noting. SecureRandom.random_number(10**6) can return numbers with fewer than six digits, and “42” is a confusing pin code to receive in an email. Padding it ensures the user always sees six digits.
One thing to be aware of: the plain text pin code only exists in memory for the lifetime of that model instance. Once the record is saved and the object goes out of scope, the pin code is gone. The only time you’ll have access to it is immediately after creation, which is exactly when you need to send the email. After that, you only have the digest, which is exactly how it should be.
generates_token_for with a built-in kill switch
The second tool is generates_token_for, which was added in Rails 7.1. It creates a signed, expiring token tied to a record. You call generate_token_for(:sign_in) and get a URL-safe string that will resolve back to the record within the expiry window, in this case 15 minutes.
generates_token_for :sign_in, expires_in: 15.minutes do
expired_at.presence
end
That block is the interesting part. The value it returns gets baked into the token’s signature. When you later call find_by_token_for(:sign_in, token), Rails recalculates the block and checks whether the value still matches. If it doesn’t, the token is treated as invalid, even if the 15 minutes haven’t elapsed yet.
We’re using expired_at.presence as the value. When the token is first created, expired_at is nil, so the block returns nil. That nil gets signed into the token. When someone uses either authentication method (magic link or pin code), we call expire!, which sets expired_at to the current time. Now the block returns a timestamp rather than nil. The signature no longer matches. The token is dead.
This gives us single-use tokens for free. The moment either the magic link or the pin code is used, both are invalidated. One authentication token, two paths in, but only one of them can work, and only once.
The flow
The user enters their email address, which creates an AuthenticationToken and sends an email:
class SignInsController < ApplicationController
def create
token = AuthenticationToken.new(email_address: params.expect(:email_address))
if token.save
AuthenticationsMailer.sign_in(token, pin_code: token.pin_code).deliver_later
redirect_to pin_code_authentication_path(token)
else
render :show, status: :unprocessable_entity
end
end
end
Notice that the pin code is passed explicitly to the mailer as a keyword argument. This is deliberate. By the time the mailer runs (it’s queued with deliver_later), the in-memory pin code may no longer be accessible on the model instance, depending on how Active Job serialises the arguments. Passing it as a separate value avoids that problem entirely.
The user gets redirected to the pin code entry screen. Meanwhile, the email arrives with both options: the pin code displayed as text, and a magic link.
class AuthenticationsMailer < ApplicationMailer
def sign_in(authentication_token, pin_code:)
@pin_code = pin_code
@sign_in_token = authentication_token.generate_token_for(:sign_in)
mail(
to: authentication_token.email_address,
subject: "Sign in to your account",
)
end
end
From here the user can take either path.
Path one: the pin code
If the user reads the pin code off their phone and types it into the browser on their laptop:
class CodeAuthenticationsController < ApplicationController
def update
@token_id = params.expect(:id)
if token = AuthenticationToken.not_expired.authenticate_by(
id: @token_id,
pin_code: params.expect(:pin_code))
token.expire!
redirect_to success_path
else
render :show, status: :unprocessable_entity
end
end
end
authenticate_by is another Rails built-in (added in 7.1). It finds a record matching the non-password attributes and then authenticates against the secure password attribute. So authenticate_by(id: @token_id, pin_code: "123456") finds the token by ID, then checks whether “123456” matches the pin code digest. If both match, you get the record. If either fails, you get nil. It’s also designed to prevent timing attacks, taking constant time regardless of whether the ID lookup or the password check is the part that failed.
The not_expired scope is a belt-and-braces check. Technically the token should still have a nil expired_at if nobody has used it yet, but scoping the query makes the intent clear and protects against edge cases.
Path two: the magic link
There’s a subtle problem with magic links that catches people out. Enterprise email providers, Microsoft Outlook in particular, will pre-fetch links in emails to check they’re safe. If your magic link triggers authentication on a GET request, the email scanner consumes the token before the user ever sees it. The user clicks the link, gets an “invalid or expired” error, and has no idea why.
The fix is to make the magic link land on a confirmation page rather than authenticating immediately. The GET request just renders a page with a “Sign in” button:
<%= button_to "Sign in", link_authentication_path(@sign_in_token), method: :put %>
The button_to generates a form that sends a PUT request. Email scanners will follow links (GET requests), but they won’t submit forms. So the scanner hits the landing page, sees a button, and moves on. The token stays intact. When the actual user clicks through, they see the button, press it, and the PUT fires:
class LinkAuthenticationsController < ApplicationController
def show
@sign_in_token = params.expect(:sign_in_token)
end
def update
@sign_in_token = params.expect(:sign_in_token)
if token = AuthenticationToken.find_by_token_for(:sign_in, @sign_in_token)
token.expire!
redirect_to success_path
else
render :show, status: :unprocessable_entity
end
end
end
find_by_token_for resolves the signed token back to a record. If the token has expired (past 15 minutes) or the expired_at value has changed since the token was generated, it returns nil. Otherwise you get the record, call expire!, and sign the user in.
It’s a small thing, but it’s the kind of thing that will have you debugging phantom “token expired” reports for weeks if you miss it.
The routes
The routing is straightforward REST:
resource :sign_in, only: [:show, :create]
resources :pin_code_authentications, controller: 'code_authentications', only: [:show, :update]
resources :link_authentications, controller: 'link_authentications', only: [:show, :update], param: :sign_in_token
resource :success, only: :show
Pin code authentications are looked up by the token’s database ID (the default :id param). Link authentications are looked up by the signed token string, so we override the param to :sign_in_token. Both use update as the action because verifying a token is a state change on the record. It feels right as a PUT.
The migration
Nothing surprising here. The table is minimal:
create_table :authentication_tokens do |t|
t.string :email_address, null: false
t.string :pin_code_digest, null: false
t.datetime :expired_at
t.timestamps
end
expired_at is nullable because tokens start un-expired. pin_code_digest is not nullable because every token gets a pin code on creation. No foreign keys to a users table because this is purely an authentication mechanism. You’d wire it up to your user model in the controllers when you integrate it into a real application.
What you get
With one model, one mailer, and three controllers, you have a complete passwordless authentication system that handles both magic links and pin codes. The entire thing leans on three Rails features: has_secure_password with a custom attribute, generates_token_for with a value-based invalidation block, and authenticate_by for timing-safe lookups.
No gems beyond bcrypt. No token tables with explicit expiry columns you have to check manually. No separate models for “magic link tokens” and “pin code tokens.” One record, two ways in, self-expiring, single-use.
There we have it. Ship it.