← Back to blog

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.

Updated November 4, 2026
Magic Link Login in Rails (2026) with a Plain HTTP Email API

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/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY in 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

  1. Issue throttle — e.g. one new link per email per minute; also cap by IP.
  2. Short TTL — ~15–30 minutes for login; shorter is fine for step-up.
  3. Hash at rest — store SHA-256 hex only; raw token lives in the email URL.
  4. Upsert — new request invalidates the previous unused hash for that user.
  5. Consume atomically — DELETE … RETURNING (or locked destroy) so double-click races fail safely.
  6. Enumeration-safe request — same status and body whether the address exists.
  7. Idempotency-Key — stable per logical send (magic- + hash prefix); new token → new key.
  8. 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 linkOTP (six digits)
Docs shapeSame as password resetVerification codes
Rails placementMagicLinkService + /auth/magic/*Separate OTP service + controllers
UXOne clickType digits
StorageSHA-256 of high-entropy tokenHMAC of email:code + pepper
Typical TTL~30 minutes~10 minutes
Good forDesktop/mobile browser sign-inShared 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

  1. Domain verified; SPF/DKIM/DMARC green for the auth subdomain.
  2. Click tracking off on that subdomain (or a dedicated security domain).
  3. ROVELA_API_KEY with sending_access only in credentials / secrets manager—never in a compiled frontend or public docs “try it.”
  4. MAGIC_FROM uses the verified subdomain; html + text both present; full URL in text.
  5. Idempotency-Key on every logical send; redeem is delete-and-return.
  6. Enumeration-safe request response; rate-limit request (and redeem).
  7. Treat send 200 as 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

  1. Implement token + Rails service from the password-reset example.
  2. Send with POST /emails · Quick Start · Authentication.
  3. Harden deliverability: OTP / magic-link spam.
  4. Compare UX primitives: code vs link.
  5. Sibling stacks: NestJS magic link · Next.js magic link · FastAPI magic link.
  6. 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.