Magic Link Login in Rails (2026) with a Plain HTTP Email API
Build passwordless magic-link login in Rails: SecureRandom tokens hashed in the DB, a controller + service, Net::HTTP to POST /emails with Idempotency-Key—prefer HTTP over Action Mailer SMTP; Rovela delivers only.
Why Rails + a plain HTTP email API
Rails already gives passwordless login a clean shape: a controller that accepts an email and returns a bland “if that address has an account…” response, a small service object that mints a token and upserts a row, and a redeem action that spends the token and starts a session. You do not need a Rovela Ruby gem for magic links, an Action Mailer SMTP plugin that pretends the ESP is an identity provider, or a special product endpoint for login URLs.
What you need is the same token recipe as password reset links: your app creates a single-use token with SecureRandom, stores a SHA-256 of it, puts the raw token only in the email URL, and POST /emails delivers the message. Rovela queues and DKIM-signs; it does not mint sessions or validate tokens.
Rovela does not provide a magic-link feature. Your Rails app owns cryptography, the database row, expiry, single-use redeem, and the session cookie. Rovela only delivers the mail.
This article is Rails / Ruby focused (SecureRandom, Active Record, a controller + service, Net::HTTP to Rovela). It prefers the HTTP API over Action Mailer SMTP for auth mail: one JSON POST, an Idempotency-Key, and no local MTA. For Nest modules, see Magic Link Login in NestJS. For App Router, see Magic Link Login in Next.js. For FastAPI, see Magic Link Login in FastAPI. For the product-agnostic recipe, see Magic Login Email Links. For code-vs-link product choice, see Email verification: code vs link. Generic Ruby send patterns live in Send email with Ruby—this post does not rehash that hello-world send.
What you own vs what Rovela sends
Your Rails app owns:
SecureRandom.urlsafe_base64(32)(or 32 random bytes → URL-safe) as the raw token- SHA-256 hex of the token in your database (never store the raw token)
- Expiry (the docs example uses ~30 minutes for reset-style links)
- Absolute URL on your origin:
https://app.example.com/auth/magic?token=… - Atomic redeem (delete-and-return) + session issuance
- Identical API responses whether or not the email is registered
- Rate limits on request and redeem
Rovela owns:
POST https://api.rovela.dev/emailswithAuthorization: Bearer+ asending_accesskey (ROVELA_API_KEYin credentials / ENV—never commit it, never expose it to the browser)- Queueing, signing, retries, bounce/complaint handling
A 200 from the send endpoint means accepted and queued, not “in the inbox.”
Credentials and a thin Rovela client (Net::HTTP)
Keep secrets in Rails.application.credentials or ENV—not in the repo:
# config / ENV (illustrative)
# ROVELA_API_KEY=re_... # sending_access
# MAGIC_FROM=Acme <security@mail.acme.com>
# MAGIC_LINK_BASE_URL=https://app.example.com
# MAGIC_TTL_MINUTES=30
# app/services/rovela_client.rb
require "net/http"
require "json"
require "uri"
class RovelaClient
ENDPOINT = URI("https://api.rovela.dev/emails")
def self.send_email(to:, subject:, html:, text:, idempotency_key:)
api_key = ENV.fetch("ROVELA_API_KEY") # sending_access
from = ENV.fetch("MAGIC_FROM", "Acme <security@mail.acme.com>")
body = {
from: from,
to: to,
subject: subject,
html: html,
text: text
}
http = Net::HTTP.new(ENDPOINT.host, ENDPOINT.port)
http.use_ssl = true
http.open_timeout = 5
http.read_timeout = 20
req = Net::HTTP::Post.new(ENDPOINT)
req["Authorization"] = "Bearer #{api_key}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idempotency_key
req.body = JSON.generate(body)
res = http.request(req)
unless res.is_a?(Net::HTTPSuccess)
Rails.logger.error("rovela send failed #{res.code} #{res.body}")
raise "rovela_send_failed"
end
JSON.parse(res.body)
end
end
Why not Action Mailer + SMTP for this path? Mailer is fine for digests and marketing templates, but magic-link sends want a stable Idempotency-Key, both html and text in one JSON body, and no SMTP credentials on the app servers. Call Rovela over HTTPS from the service; keep Action Mailer for other mail if you already use it. Auth: Authentication · wire format: API: emails · first send: Quick Start.
Model: hash only, short TTL
# db/migrate/xxxx_create_magic_logins.rb
class CreateMagicLogins < ActiveRecord::Migration[7.1]
def change
create_table :magic_logins do |t|
t.references :user, null: false, foreign_key: true, index: { unique: true }
t.string :token_hash, null: false, limit: 64
t.datetime :expires_at, null: false
t.timestamps
end
add_index :magic_logins, :token_hash, unique: true
end
end
# app/models/magic_login.rb
class MagicLogin < ApplicationRecord
belongs_to :user
end
Plain SHA-256 is enough for a high-entropy URL-safe token (unlike six-digit OTPs, which need HMAC + pepper—see verification codes).
Token helper + service: create, store, send
# app/services/magic_link_service.rb
require "digest"
require "securerandom"
class MagicLinkService
SAFE_MSG = "If that address has an account, a sign-in link is on its way."
def self.request_link(raw_email)
email = raw_email.to_s.strip.downcase
user = User.find_by(email: email)
if user
token = SecureRandom.urlsafe_base64(32)
token_hash = Digest::SHA256.hexdigest(token)
ttl = Integer(ENV.fetch("MAGIC_TTL_MINUTES", "30"))
expires_at = ttl.minutes.from_now
MagicLogin.upsert(
{
user_id: user.id,
token_hash: token_hash,
expires_at: expires_at,
updated_at: Time.current,
created_at: Time.current
},
unique_by: :user_id
)
base = ENV.fetch("MAGIC_LINK_BASE_URL").to_s.chomp("/")
link = "#{base}/auth/magic?token=#{token}"
send_magic_email(email, link, token_hash)
end
{ message: SAFE_MSG }
end
def self.redeem(token)
return nil if token.blank?
token_hash = Digest::SHA256.hexdigest(token)
# Spend in one step so two parallel clicks cannot both succeed.
row = MagicLogin.where(token_hash: token_hash)
.where("expires_at > ?", Time.current)
.delete_all_returning(:user_id) # use a DELETE … RETURNING helper / raw SQL
# Portable fallback without delete_all_returning:
# MagicLogin.transaction do
# rec = MagicLogin.lock.find_by(token_hash: token_hash)
# return nil unless rec && rec.expires_at > Time.current
# uid = rec.user_id
# rec.destroy!
# uid
# end
row&.first
end
def self.send_magic_email(to, link, token_hash)
RovelaClient.send_email(
to: to,
subject: "Your Acme sign-in link",
html: <<~HTML,
<p>Use this link to sign in to Acme.</p>
<p><a href="#{link}">Sign in</a></p>
<p>The link works once and expires in 30 minutes.
If you did not request it, ignore this email.</p>
HTML
text: <<~TEXT,
Use this link to sign in to Acme.
Sign in: #{link}
The link works once and expires in 30 minutes.
TEXT
idempotency_key: "magic-#{token_hash[0, 32]}"
)
end
private_class_method :send_magic_email
end
Always send both html and text, with the full URL in the text part. Use a verified subdomain for auth (security@mail.…). Derive Idempotency-Key from the token hash so a timeout retry does not enqueue a second message; a deliberate “resend” means a new token and a new key.
For the redeem SQL, prefer a single DELETE … WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id (Postgres) so double-click races fail safely—the comment shows a locked Active Record fallback if you are not on Postgres.
Controller: request + redeem
# config/routes.rb
namespace :auth do
resource :magic, only: [], controller: "magic" do
post :request_link # POST /auth/magic/request_link
post :redeem # POST /auth/magic/redeem
end
end
get "/auth/magic", to: "auth/magic#show" # landing page reads ?token=
# app/controllers/auth/magic_controller.rb
module Auth
class MagicController < ApplicationController
skip_before_action :require_login, raise: false # if you have one
rate_limit to: 10, within: 1.minute, only: %i[request_link redeem], if: -> { true } # Rails 8+; or rack-attack
def show
# Render a page that reads params[:token] and POSTs JSON to redeem.
# Do not create a session on GET — email scanners prefetch links.
end
def request_link
# Also throttle by email + IP (rack-attack / Redis).
payload = MagicLinkService.request_link(params.require(:email))
render json: payload
end
def redeem
token = params.require(:token)
user_id = MagicLinkService.redeem(token)
unless user_id
return render json: { error: "expired_or_used" }, status: :bad_request
end
user = User.find(user_id)
reset_session
session[:user_id] = user.id
render json: { ok: true }
end
end
end
Prefer POST redeem over creating a session on GET: email scanners and link prefetchers often hit GET URLs. If you must support a GET landing page, show “Continue” and only issue the session after an explicit user action.
For ownership confirmation (not login), the same service shape applies with a different table and path—see Email verification links. Password reset is the docs twin: Secure password reset email.
Keep click tracking off for the login URL
Open/click tracking is a per-domain setting. When click tracking is on, Rovela rewrites links through a tracking host. The login still works after the redirect, but the token takes an extra hop.
For magic links (and password resets / invite tokens), prefer a separate sending domain or subdomain with click tracking off, so the token goes straight to your Rails app. That is the same guidance as the password-reset example. If auth mail still lands in spam, use the checklist in Why OTP and Magic-Link Emails Go to Spam.
Rate limit / single-use / expiry checklist
- Issue throttle — e.g. one new link per email per minute; also cap by IP.
- Short TTL — ~15–30 minutes for login; shorter is fine for step-up.
- Hash at rest — store SHA-256 hex only; raw token lives in the email URL.
- Upsert — new request invalidates the previous unused hash for that user.
- Consume atomically —
DELETE … RETURNING(or locked destroy) so double-click races fail safely. - Enumeration-safe request — same status and body whether the address exists.
- Idempotency-Key — stable per logical send (
magic-+ hash prefix); new token → new key. - Prefetch-safe redeem — prefer POST redeem; if you must support GET, show “Continue” without creating a session until an explicit action.
curl equivalent (agents / smoke tests)
Same wire format your Rails service posts—useful for local smoke tests and AI agents that call HTTP directly (AI agent send email):
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer re_your_sending_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: magic-smoke-2026-11-05" \
-d '{
"from": "Acme <security@mail.acme.com>",
"to": "alice@example.com",
"subject": "Your Acme sign-in link",
"html": "<p><a href=\"https://app.acme.com/auth/magic?token=TEST\">Sign in</a></p><p>Expires in 30 minutes.</p>",
"text": "Sign in: https://app.acme.com/auth/magic?token=TEST\n\nExpires in 30 minutes."
}'
Replace TEST with a real token only in non-production. Production keys need sending_access; from must be on a verified domain (Quick Start).
Magic link vs OTP in the same Rails app
| Magic link | OTP (six digits) | |
|---|---|---|
| Docs shape | Same as password reset | Verification codes |
| Rails placement | MagicLinkService + /auth/magic/* | Separate OTP service + controllers |
| UX | One click | Type digits |
| Storage | SHA-256 of high-entropy token | HMAC of email:code + pepper |
| Typical TTL | ~30 minutes | ~10 minutes |
| Good for | Desktop/mobile browser sign-in | Shared screens, step-up |
Both call the same POST /emails. Keep separate tables and services—do not reuse an OTP row as a magic-link token. Product choice: code vs link. OTP siblings in other stacks: Express OTP · Django OTP.
Deploy checklist
- Domain verified; SPF/DKIM/DMARC green for the auth subdomain.
- Click tracking off on that subdomain (or a dedicated security domain).
ROVELA_API_KEYwithsending_accessonly in credentials / secrets manager—never in a compiled frontend or public docs “try it.”MAGIC_FROMuses the verified subdomain; html + text both present; full URL in text.Idempotency-Keyon every logical send; redeem is delete-and-return.- Enumeration-safe request response; rate-limit request (and redeem).
- Treat send
200as queued; use webhooks for bounces/complaints if you need delivery truth (Webhooks).
What this post is not
- Not a full production auth framework (OAuth2, Devise deep-dive, MFA).
- Not a duplicate of the NestJS, Next.js, FastAPI, or generic magic login posts—those cover Nest modules, App Router, FastAPI routers, and framework-agnostic flows.
- Not a rehash of generic Send email with Ruby tutorials or Action Mailer SMTP setup.
- Not a claim that Rovela stores, validates, or expires your tokens.
- Not China-domestic ESP comparison or invented product features.
Next steps
- Implement token + Rails service from the password-reset example.
- Send with POST /emails · Quick Start · Authentication.
- Harden deliverability: OTP / magic-link spam.
- Compare UX primitives: code vs link.
- Sibling stacks: NestJS magic link · Next.js magic link · FastAPI magic link.
- Limits: Pricing.
Bottom line: put magic login in a Rails controller + service that hashes SecureRandom tokens and calls one Net::HTTP POST to Rovela—prefer HTTP over Action Mailer SMTP for this path, keep click tracking off on the security domain, and never pretend the email API is an identity provider.