← Back to blog

Magic Login Email Links (2026): Passwordless Sign-In with Rovela

Build a single-use login link in your app, then deliver it with POST /emails—same pattern as password reset, with tracking off for security mail.

Updated November 4, 2026
Magic Login Email Links (2026): Passwordless Sign-In with Rovela

Passwordless login without a special email API

Passwordless login — the “magic link” pattern — is one of the first things you need when you ship an AI app, a SaaS prototype, or a side project that signs people in by email. The user types an address, you send a one-time link, they click it, and your app creates a session. No password to store, no “forgot password” flow to maintain for that account.

Rovela does not ship a dedicated magic-login product or endpoint. What it does ship is the same transactional send path every other auth email uses: POST /emails. The closest official recipe is password reset links: generate an unguessable token in your app, store a hash with a short expiry, put the raw token only in the email, redeem it once. Magic login is that pattern with a different landing route — create a session instead of setting a password.

If you already send OTPs with Rovela’s verification-code example, keep that for “type six digits” flows. This article is for the link-shaped variant: one click, one session.

What you build vs what Rovela delivers

Your application owns:

  • Cryptographic token generation and hashing
  • Persistence (hash, user id, expiry, single-use consumption)
  • The absolute URL on your origin (https://app.example.com/login/magic?token=…)
  • The redeem route that validates the token and creates the session
  • Rate limits on “send me a link,” identical responses when the address has no account, and server-side calls only

Rovela delivers:

  • Accepting POST https://api.rovela.dev/emails with a Bearer re_… key (sending_access is enough)
  • Queueing, DKIM signing for your verified domain, retries, bounce/complaint handling, and the message log
  • Optional templates, webhooks, and suppressions — same infrastructure as any transactional message

Do not put the API key in a browser or mobile client. Call Rovela from your backend. Plans and volume limits live on pricing; this article invents none of those numbers.

Generate and store the token (32 bytes, hash, expiry)

Follow the same entropy rules as the password-reset docs:

  1. Normalize the email (trim + lowercase).
  2. Look up the user. If they exist, generate 32 random bytes and encode as base64url.
  3. Store only the SHA-256 hex of the token — never the token itself. Thirty-two bytes are not brute-forceable from the hash, so plain SHA-256 is enough (OTP codes are different: short digits need HMAC with a server pepper).
  4. Set expiry around 30 minutes.
  5. Build https://app.acme.com/login/magic?token= + the raw token. The token appears only in the email URL.

Always return the same message whether or not the account exists — same copy, same status, ideally same timing (enqueue the send). Otherwise your “email me a login link” form becomes an account-enumeration oracle.

Send the link with POST /emails (html+text, Idempotency-Key, subdomain from)

Use a verified subdomain sender for auth reputation (for example security@mail.acme.com), send both html and text, and put the full URL in the plain-text part. Corporate filters sometimes strip HTML links; the text body is the fallback.

Set an Idempotency-Key once per logical send (≤256 characters, 24-hour window, scoped to the organization). Derive it from a stable prefix of the token hash so a timeout retry does not mint a second email. A user tapping “resend” is a new logical send — generate a new token and a new key.

Sketch adapted from the password-reset example (placeholders only):

import crypto from "node:crypto"

export async function requestMagicLink(rawEmail) {
  const email = rawEmail.trim().toLowerCase()
  const user = await db.users.findByEmail(email)

  if (user) {
    const token = crypto.randomBytes(32).toString("base64url")
    const tokenHash = crypto.createHash("sha256").update(token).digest("hex")

    await db.magicLogins.upsert({
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + 30 * 60 * 1000),
    })

    const link = "https://app.acme.com/login/magic?token=" + token

    await fetch("https://api.rovela.dev/emails", {
      method: "POST",
      headers: {
        Authorization: "Bearer " + process.env.ROVELA_API_KEY,
        "Content-Type": "application/json",
        "Idempotency-Key": "magic-" + tokenHash.slice(0, 32),
      },
      body: JSON.stringify({
        from: "Acme <security@mail.acme.com>",
        to: email,
        subject: "Your Acme sign-in link",
        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, you can ignore this email.</p>",
        text:
          "Use this link to sign in to Acme.\n\n" +
          "Sign in: " + link + "\n\n" +
          "The link works once and expires in 30 minutes.",
      }),
    })
  }

  return { message: "If that address has an account, a sign-in link is on its way." }
}

A 200 from Rovela means the message was accepted and queued, not that it landed in the inbox. Wire webhooks (email.bounced, email.complained, suppressions) if you need to detect undeliverable auth mail. Full field reference: Send Email API.

Redeem once and create the session

Spend the token in one database step so parallel clicks cannot both succeed:

export async function redeemMagicLink(token) {
  const tokenHash = crypto.createHash("sha256").update(token).digest("hex")

  const row = await db.query(
    `DELETE FROM magic_logins
     WHERE token_hash = $1 AND expires_at > NOW()
     RETURNING user_id`,
    [tokenHash]
  )

  if (row.rowCount === 0) return null

  const userId = row.rows[0].user_id
  const session = await db.sessions.create(userId)
  return session
}

On success, create the session cookie (or JWT) your app already uses. On failure, show a generic “link expired or already used” page and offer to request a new one. Do not reveal whether the token was unknown, expired, or already spent if that distinction helps an attacker.

Unlike password reset, you usually do not revoke every other session after a magic login — the user asked to sign in, not to rotate credentials. If your threat model treats every magic login as a high-risk event, you can still notify the account by email after a successful redeem; that notice is optional product policy, not a Rovela requirement.

Turn click tracking off for security mail

Open and click tracking are per-domain settings. When click tracking is on, Rovela rewrites links through its tracking host. The login URL still works after the redirect, but the token briefly travels through an extra hop.

For security mail — magic links, password resets, invite tokens — prefer a separate sending domain (or subdomain) with click tracking off, so the token goes straight to your app. That guidance is documented on the password-reset example. Engagement webhooks for opens/clicks will not fire on a domain with tracking disabled; that is expected.

Failure modes (suppression 400, unverified domain, 429)

  • Suppressed recipient. For a single POST /emails, the verification-code docs document a 400 whose message starts with recipient is suppressed. Treat that as undeliverable: ask for another address; do not say “check your inbox.”
  • Unverified from domain. Sending from an address whose domain is not verified returns 400 naming the domain. Finish domain verification before shipping auth mail.
  • Rate limits / 429. Back off and retry with the same Idempotency-Key for the same logical send. Intentional resend = new token + new key.
  • Missing or wrong API key scope. Production senders should use sending_access. Keys belong on the server only (authentication).

OTP vs magic link (when to use which)

OTP (code)Magic link
Docs recipeVerification codesSame send shape as password reset; no dedicated magic-link page
User actionType digitsClick once
Secret storageHMAC of email:code + pepper; short codes are brute-forceableSHA-256 of 32-byte token
Typical expiry~10 minutes in the docs example~30 minutes in the reset example
Good forShared screens, “confirm it is you” step-up, SMS-style UX over emailDesktop/mobile browsers where one tap finishes sign-in
Do not mergeKeep code verify and link redeem as separate tables and routes

Both paths call the same POST /emails endpoint. Rovela queues and delivers; your app decides whether the payload is six digits or a URL.

Ship the token machinery yourself, send the message with one HTTP call, keep tracking off on the security domain, and you have passwordless sign-in without pretending the email API is an identity provider.