← Back to blog

Magic Link Login in NestJS (2026) with a Plain HTTP Email API

Build passwordless magic-link login in NestJS modules: hashed tokens, expiry, POST /emails via fetch/HttpService, Idempotency-Key, and click tracking off for security URLs.

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

Why NestJS + a plain HTTP email API

NestJS already gives passwordless login the structure you want: an AuthModule, a service that owns crypto and persistence, and controllers that expose “email me a link” and “redeem this token.” You do not need a Rovela SDK, a Passport “magic login” strategy, or a special product endpoint for magic links.

What you need is the same token recipe as password reset links: your app creates a single-use token, stores a hash, 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 Nest app owns cryptography, the database row, expiry, single-use redeem, and the session cookie. Rovela only delivers the mail.

This article is Nest-module focused. For App Router Route Handlers, see Magic Link Login in Next.js. For the product-agnostic recipe, see Magic Login Email Links. For code-vs-link product choice, see Email verification: code vs link.

What you own vs what Rovela sends

Your NestJS app owns:

  • crypto.randomBytes(32) → base64url 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 / JWT 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 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.”

Module sketch

Keep magic login inside auth, not inside a generic “mail” module that also sends newsletters:

// auth/auth.module.ts
import { Module } from '@nestjs/common'
import { HttpModule } from '@nestjs/axios' // optional if you prefer HttpService
import { MagicLinkService } from './magic-link.service'
import { MagicLinkController } from './magic-link.controller'

@Module({
  imports: [HttpModule],
  controllers: [MagicLinkController],
  providers: [MagicLinkService],
})
export class AuthModule {}

Wire ROVELA_API_KEY, MAGIC_LINK_BASE_URL (your app origin), and MAGIC_FROM (verified subdomain address) via ConfigModule / environment—same idea as Nest config for any secret.

Token helper (32 bytes, hash only, short TTL)

// auth/magic-token.ts
import crypto from 'node:crypto'

export function createMagicToken(ttlMinutes = 30) {
  const token = crypto.randomBytes(32).toString('base64url')
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex')
  const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000)
  return { token, tokenHash, expiresAt }
}

Upsert by user: replace any previous unused link so only the latest email works. Plain SHA-256 is enough for 32 random bytes (unlike six-digit OTPs, which need HMAC + pepper—see verification codes).

Service: create, store, send via fetch (or HttpService)

// auth/magic-link.service.ts
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import crypto from 'node:crypto'
import { createMagicToken } from './magic-token'
// import { UsersRepo, MagicLoginsRepo } from '../db' // your store

@Injectable()
export class MagicLinkService {
  private readonly log = new Logger(MagicLinkService.name)

  constructor(private readonly config: ConfigService) {}

  /** Always return the same client-facing message (enumeration-safe). */
  async requestLink(rawEmail: string) {
    const email = String(rawEmail ?? '').trim().toLowerCase()
    const user = await this.users.findByEmail(email)

    if (user) {
      const { token, tokenHash, expiresAt } = createMagicToken(30)
      await this.magicLogins.upsert({ userId: user.id, tokenHash, expiresAt })

      const base = this.config.getOrThrow<string>('MAGIC_LINK_BASE_URL')
      const link = `${base}/auth/magic?token=${token}`
      await this.sendMagicEmail(email, link, tokenHash)
    }

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

  async redeem(token: string) {
    if (!token || typeof token !== 'string') return null
    const tokenHash = crypto.createHash('sha256').update(token).digest('hex')

    // Spend in one step so two parallel clicks cannot both succeed.
    const userId = await this.magicLogins.consumeIfValid(tokenHash)
    return userId // null if expired / used / unknown
  }

  private async sendMagicEmail(to: string, link: string, tokenHash: string) {
    const apiKey = this.config.getOrThrow<string>('ROVELA_API_KEY')
    const from = this.config.getOrThrow<string>('MAGIC_FROM')
    // e.g. "Acme <security@mail.acme.com>"

    const res = await fetch('https://api.rovela.dev/emails', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': `magic-${tokenHash.slice(0, 32)}`,
      },
      body: JSON.stringify({
        from,
        to,
        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, 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.`,
      }),
    })

    if (!res.ok) {
      // Log server-side; still return the same client message from requestLink.
      this.log.error(`rovela send failed ${res.status} ${await res.text()}`)
    }
  }
}

Prefer Node’s built-in fetch (Nest 10+ / Node 18+) for one outbound call. If your codebase standardizes on @nestjs/axios, the same URL, headers, and JSON body work with HttpService.post—do not wrap Rovela in a custom SDK.

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 (API: emails, Authentication).

Controller: request + verify

// auth/magic-link.controller.ts
import { Body, Controller, HttpCode, Post, Res } from '@nestjs/common'
import type { Response } from 'express'
import { MagicLinkService } from './magic-link.service'
// import { SessionService } from './session.service'

@Controller('auth/magic')
export class MagicLinkController {
  constructor(
    private readonly magic: MagicLinkService,
    private readonly sessions: SessionService,
  ) {}

  @Post('request')
  @HttpCode(200)
  async request(@Body('email') email: string) {
    // Apply rate limiting here (IP + email): Nest Throttler or a reverse proxy.
    return this.magic.requestLink(email)
  }

  @Post('verify')
  async verify(@Body('token') token: string, @Res({ passthrough: true }) res: Response) {
    const userId = await this.magic.redeem(token)
    if (!userId) {
      return res.status(400).json({ error: 'expired_or_used' })
    }
    const cookie = await this.sessions.createCookie(userId)
    res.setHeader('Set-Cookie', cookie)
    return { ok: true }
  }
}

Serve a page at /auth/magic that reads ?token= and POSTs to /auth/magic/verify. On failure, show a generic “link expired or already used” UI and a button to request a new link—do not distinguish unknown vs spent vs expired if that helps an attacker.

For ownership confirmation (not login), the same Nest 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 Nest 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 … WHERE hash AND expires_at > now() RETURNING (or equivalent) 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 verify (as above); if you must support GET, show “Continue” without creating a session until an explicit action (scanners prefetch GETs).

curl equivalent (agents / smoke tests)

Same wire format your Nest 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 Nest app

Magic linkOTP (six digits)
Docs shapeSame as password resetVerification codes
Nest placementMagicLinkService + /auth/magic/*Separate OtpService + routes
UXOne clickType digits
StorageSHA-256 of 32-byte 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.

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 server env / secrets manager—never in the Nest client bundle.
  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 (Passport strategies, refresh rotation, MFA).
  • Not a duplicate of the Next.js magic-link or generic magic login posts—those cover App Router and framework-agnostic flows.
  • 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 + Nest 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. Limits: Pricing.

Bottom line: put magic login in a Nest AuthModule service that hashes tokens and calls one fetch to Rovela—keep click tracking off on the security domain, and never pretend the email API is an identity provider.