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.
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)→base64urltoken- 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/emailswithAuthorization: Bearer+ asending_accesskey (ROVELA_API_KEYin 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
- 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 … WHERE hash AND expires_at > now() RETURNING(or equivalent) 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 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 link | OTP (six digits) | |
|---|---|---|
| Docs shape | Same as password reset | Verification codes |
| Nest placement | MagicLinkService + /auth/magic/* | Separate OtpService + routes |
| UX | One click | Type digits |
| Storage | SHA-256 of 32-byte 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.
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 server env / secrets manager—never in the Nest client bundle.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 (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
- Implement token + Nest 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.
- 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.