Magic-link mail. Brevo SMTP, sender reputation, the +tag trap
Passwordless auth via one-time email links. The DKIM/SPF/DMARC setup that prevents Yahoo bounces, the token TTL pattern, the +tag email gotcha, and the deliverability checklist.
Magic-link mail. Brevo SMTP, sender reputation, the +tag trap
Magic-link auth is the cheapest production-grade auth: no password storage, no password-reset flows, no breach exposure. The catch is email, one bad sender setup and your delivery rate drops to 60%, half your signups silently fail. This recipe is the production setup that survives Yahoo, Outlook, and GMX.
Schritt 1: Pick an SMTP provider with a free tier
Four options that work for solo SaaS:
| Provider | Free tier | DKIM/DMARC | Notes | |---|---|---|---| | Brevo (ex-Sendinblue) | 300/day forever | Yes | EU-based, good for DACH compliance. Shared-IP pool, deliverability varies. | | Resend | 3k/mo, 100/day | Yes | Modern DX, React Email templates, good docs. US-based. | | Postmark | 14-day trial only ($15/mo for 10k after) | Yes | Best transactional deliverability in the industry. Transactional-only, they refuse marketing mail. | | Mailgun | Pay-as-you-go ($35/50k) | Yes | Mature deliverability, less-friendly DX. |
Pragmatic default for a solo SaaS:
- Brevo if you're in the EU and price-sensitive, €0/month for 300 emails/day is hard to beat.
- Resend if you're shipping fast and want better DX (typed SDK, React Email).
- Postmark if you've already had deliverability problems and need to fix them, their inbox rate consistently beats the alternatives. The €15/mo is cheap insurance for a SaaS with paying users.
If you start with Brevo and notice your magic-links bouncing more than 1% (check the Brevo dashboard weekly), the upgrade path is Resend → Postmark. The DNS setup in Step 2 transfers across all three.
Schritt 2: DNS setup, the only step that determines deliverability
Yahoo, Gmail, Outlook, and most large providers require DKIM + SPF + DMARC. Without them, your magic-links bounce silently. With them, your delivery rate stays above 95%.
For Brevo, you'll add three DNS records to your domain (e.g., studiomeyer.io):
Type Name Value
TXT studiomeyer.io "v=spf1 include:spf.brevo.com ~all"
TXT mail._domainkey.studiomeyer.io "k=rsa; p=MIGfMA0...<long key from Brevo>"
TXT _dmarc.studiomeyer.io "v=DMARC1; p=quarantine; rua=mailto:[email protected]"
Important about the SPF record: Use ~all (soft-fail), not -all (hard-fail), as the default. Reason: most domains already send mail through other providers (Google Workspace, Microsoft 365, your CRM, your invoicing tool), a hard-fail SPF will silently kill all of those. If you're certain Brevo is the only sender for this domain AND you've added every other sender's include: directive, then -all is correct. Otherwise stick with ~all.
If you already have an SPF record on the domain, append include:spf.brevo.com to it instead of creating a second one, multiple SPF records are an RFC violation and most receivers reject the domain entirely.
Get the exact DKIM value from Brevo dashboard → Senders & IP → Authenticate your domain. Wait 30-60 minutes for DNS propagation, then verify in the Brevo UI. Don't skip the verify step, you find typos that way.
Schritt 3: nodemailer transport in your server
// src/lib/email-magic.ts
import nodemailer from 'nodemailer';
import { logger } from './logger.js';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST!, // smtp-relay.brevo.com
port: Number(process.env.SMTP_PORT ?? 587),
secure: false, // STARTTLS on port 587
auth: {
user: process.env.SMTP_USER!, // your Brevo login
pass: process.env.SMTP_PASS!, // your Brevo SMTP key (NOT your account password)
},
});
export async function sendMagicLink(to: string, link: string): Promise<void> {
// Reject the +tag trap upfront (Step 5 explains why)
if (to.includes('+')) throw new Error('PLUS_TAG_NOT_ALLOWED');
const html = `
<p>Hi,</p>
<p>Click this link to sign in. The link expires in 15 minutes and can only be used once.</p>
<p><a href="${link}">${link}</a></p>
<p>If you didn't request this, ignore this email.</p>
<hr>
<p style="color: #666; font-size: 12px;">
Sent by My SaaS · <a href="https://mysaas.io/unsubscribe">Unsubscribe</a>
</p>
`;
await transporter.sendMail({
from: '"My SaaS" <[email protected]>', // matches your verified DKIM domain
to,
subject: 'Sign in to My SaaS',
text: `Sign in: ${link}\n\nThis link expires in 15 minutes.`,
html,
headers: {
'List-Unsubscribe': '<mailto:[email protected]>',
'X-Entity-Ref-ID': `magic-${Date.now()}`, // dedupe header for some providers
},
});
logger.info('magic link sent', { to, host: to.split('@')[1] });
}
Three rules baked into this:
frommatches the DKIM-verified domain. If your DKIM is forstudiomeyer.ioand you sendfrom: "[email protected]", every provider rejects you.- Plain-text version included. Spam filters score HTML-only emails ~30% worse.
List-Unsubscribeheader. Required by Yahoo + Gmail bulk-sender policy as of Feb 2024. Even one transactional email per user counts.
Schritt 4: Token table + endpoint
CREATE TABLE oauth_magic_links (
token TEXT PRIMARY KEY,
email TEXT NOT NULL,
tenant_id UUID, -- nullable: filled on first verify if user is new
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON oauth_magic_links (email);
CREATE INDEX ON oauth_magic_links (expires_at);
The /authorize POST handler:
import { randomBytes } from 'node:crypto';
app.post('/authorize', async (req, res) => {
const email = String(req.body.email ?? '').trim().toLowerCase();
if (!email.match(/^[^@\s]+@[^@\s]+\.[^@\s]+$/)) {
return res.status(400).send('Invalid email.');
}
if (email.includes('+')) {
return res.status(400).send('Plus-addressed emails are not supported. Use a regular address.');
}
const token = randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await db.query(
`INSERT INTO oauth_magic_links (token, email, expires_at)
VALUES ($1, $2, $3)`,
[token, email, expiresAt],
);
const link = `${process.env.BASE_URL}/authorize/verify?token=${token}`;
try {
await sendMagicLink(email, link);
} catch (err) {
logger.error('magic link mail failed', { email, err: String(err) });
return res.status(500).send('Could not send the email. Try again or contact support.');
}
res.send('Check your email, the link expires in 15 minutes.');
});
The verify endpoint:
app.get('/authorize/verify', async (req, res) => {
const token = String(req.query.token ?? '');
const r = await db.query(
`UPDATE oauth_magic_links
SET used = TRUE
WHERE token = $1 AND used = FALSE AND expires_at > NOW()
RETURNING email`,
[token],
);
if (r.rows.length === 0) {
return res.status(401).send('Invalid or expired link. Request a new one.');
}
// Find or create tenant
const email = r.rows[0].email as string;
const tenant = await findOrCreateTenantByEmail(email);
// Issue session, set cookie, redirect to dashboard
const sessionToken = await issueSession(tenant.id);
res.cookie('session', sessionToken, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30 * 24 * 3600 * 1000 });
res.redirect('/dashboard');
});
The UPDATE ... WHERE used = FALSE AND expires_at > NOW() RETURNING pattern is atomic, no race condition where two parallel clicks both succeed.
Schritt 5: The +tag email trap
Plus-tagged addresses ([email protected], [email protected]) are how power users filter their inbox. Most SaaS treat them as valid. The catch is that not every receiving server does:
- Gmail: accepts
+tagsreliably (this is where the convention comes from). - Outlook / Hotmail / Live: accepts
+tagsfor personal accounts, but Office 365 tenants can disable plus-addressing per organization, and many corporate setups do. - Yahoo: historically used
-instead of+and many Yahoo MX setups still reject+taggedaddresses outright. - GMX, t-online, AOL: mixed, some accept, some reject.
- Self-hosted / corporate MX servers: roughly 1 in 5 rejects
+tagsby default. Postfix and Exim both ship with plus-addressing disabled in some distros.
The practical reality: across a SaaS user base, between 2% and 15% of +tagged addresses bounce hard. That's enough to damage your sender reputation if you don't catch it.
Three options:
- Strip the +tag before storing (
[email protected]→[email protected]), controversial, breaks the user's stated intent (they want their filter to work). - Reject upfront with a clear message, what we do (Step 4 has the check). Pragmatic default for transactional auth.
- Accept-and-pray, what most SaaS do, and what causes the 2-15% mystery bounce ticket.
We've shipped both "accept" and "reject" policies in production. After multiple sender-reputation incidents and dozens of hard bounces traced to plus-addressing, we settled on "reject upfront with a clear message" for magic-link auth. The reasoning: magic-link is a one-time, time-sensitive signal. A bounced magic-link is a failed signup. A user who reads "plus-addressing isn't supported, please use your normal address" gets in within 30 seconds. A user who waits 15 minutes for a magic-link that bounced silently never comes back.
Schritt 6: Verify
Run academy_validate_step. The validator checks the OAuth surface (/authorize, /token, /.well-known/oauth-authorization-server) is reachable. Magic-link is one of the auth methods that gets you there.
Schritt 7: Pre-flight email checklist before going live
Run through this before your first real signup:
- DKIM signed:
dig +short TXT mail._domainkey.yourdomain.ioreturns the key - SPF correct:
dig +short TXT yourdomain.ioincludesinclude:spf.brevo.com - DMARC set:
dig +short TXT _dmarc.yourdomain.ioreturns the policy - Send a test email to: [email protected], [email protected], [email protected], [email protected], all four arrive in the inbox, not spam
- View source on the received email, confirms
dkim=pass,spf=pass,dmarc=pass - Click the magic link, confirm the redirect works
- Send to a
+taggedaddress, confirm your endpoint rejects it cleanly - Wait 24h, check Brevo dashboard for bounces, if any, fix before launch
Skipping any of these steps means you find the bug after a paying customer can't sign in.
Common traps
frommismatch with DKIM domain, silent reject by Gmail, hard bounce on Yahoo.- No plain-text version, 30% spam-score penalty. Always provide both.
- Long token TTL, 15 minutes is the sweet spot. 1 hour increases attack window for stolen emails. 5 minutes makes users miss the window.
- Reusing tokens, magic links should be one-shot. The
WHERE used = FALSEUPDATE handles it. - Plus-tag emails accepted blindly, covered in Step 5.
- Re-using your password as SMTP_PASS. Brevo gives you a separate SMTP key in their dashboard. Use that, not your account password.
What good looks like
A user enters their email on /authorize, clicks "Send link", checks inbox within 5 seconds, clicks the link, lands on the dashboard logged in. Failure modes are rare (typo'd email, expired link, +tag rejection) and have clear error messages. Bounce rate stays under 1%.
If you skip the DNS setup, you'll spend the next month debugging why your conversions are 50% of what they should be. If you skip the +tag rejection, you'll spend the next month explaining to angry Yahoo users why they can't sign in. Both are avoidable in advance.
ls -la src/saas/oauth.ts 2>/dev/null && grep -c "/authorize\|/token\|/register" src/saas/oauth.ts 2>/dev/null