resend-better-auth
Integrating Resend transactional email with Better Auth in Next.js. Use this when debugging "Resend 403 unauthorized", "magic link not sending", "OTP email not arriving", "DKIM/SPF/DMARC setup", "API key scope errors", "Better Auth email endpoints", or "twoFactor plugin password errors". Covers 7 critical pitfalls from a real 6-hour debugging session, plus observability and localization patterns.
~/.claude/skills/resend-better-authSkill local
Skill purement local : aucune source amont n'est déclarée dans le frontmatter. Les modifications vivent uniquement sur ton disque.
Resend + Better Auth Integration Guide
This skill captures hard-won knowledge about integrating Resend transactional email with Better Auth in a Next.js app. Seven concrete pitfalls are documented with symptom, cause, and fix; bonus patterns add observability, security, and localization.
Pitfall 1: send.example.com is NOT the FROM domain
Symptom
403 This API key is not authorized to send emails from send.example.com
Cause
When Resend sets up a domain (e.g., example.com), it creates DNS records with a send subdomain (send.example.com) for bounce handling. This subdomain is internal infrastructure, not a user-facing sending address. Many developers confuse the bounce subdomain with the FROM domain.
Fix
Wrong:
RESEND_FROM_EMAIL=noreply@send.example.com
Right:
RESEND_FROM_EMAIL=noreply@example.com
The From: header MUST use the root domain. The send subdomain appears only in DNS records (MX, SPF) as the return-path, never in email composition.
Pitfall 2: Resend API keys are scoped per-domain and immutable
Symptom
403 This API key is not authorized to send emails from another-domain.com
Cause
Once you create a Resend API key with "Sending access" scoped to a specific domain, the scope is locked. You cannot later expand it to include other domains. The only solution is delete + recreate.
Fix
Best Practice:
- Create one API key per environment (
gig-extranet-dev,gig-extranet-prod) - Name keys descriptively to identify the domain and environment
- Never share keys between environments
- Delete unused keys immediately (principle of least privilege)
Example key naming:
gig-extranet-prod-sending → Sends from gig-extranet.com, production
gig-extranet-dev-sending → Sends from gig-extranet.com, development
If you need to add a new domain to your sending scope, create a fresh key for it rather than trying to update an existing one.
Pitfall 3: DNS record setup for Resend (standard hosts)
Symptom
Emails failing DKIM/SPF validation, or Resend dashboard shows "DNS not verified"
Cause
Misplaced DNS records. Resend asks for 4 records; the MX, SPF, and DKIM records go in specific locations and beginners often place them at the root instead of subdomains.
Fix
For a domain example.com, add these 4 DNS records:
| Type | Host | Value | Notes |
|---|---|---|---|
| TXT | resend._domainkey.example.com | (DKIM key from Resend dashboard) | Root domain — resend._domainkey prefix |
| MX | send.example.com | feedback-smtp.eu-west-1.amazonses.com priority 10 | Subdomain send, NOT root |
| TXT | send.example.com | v=spf1 include:amazonses.com ~all | Subdomain send, NOT root |
| TXT | _dmarc.example.com | v=DMARC1; p=none; (progress later) | Root domain — _dmarc prefix |
Key detail: If your domain already has Google Workspace or other MX records on the root, they stay untouched. Resend's MX record lives on the send subdomain only — no conflict.
Verification (macOS/Linux):
# Check DKIM
dig TXT resend._domainkey.example.com +short
# Check SPF on send subdomain
dig TXT send.example.com +short
# Check DMARC at root
dig TXT _dmarc.example.com +short
DMARC Progression: Start with p=none, monitor reports for a week, then upgrade to p=quarantine, then to p=reject once you're confident legitimate mail passes.
Pitfall 4: Better Auth email-OTP endpoints (two distinct steps)
Symptom
400 [body.otp] Invalid input
when trying to send an OTP, or OTP validation fails unexpectedly.
Cause
There are two separate endpoints for email OTP flow, and they serve different purposes:
- Step 1: Send the OTP to the user's email
- Step 2: Verify the OTP and create a session
Developers often mix them up or call the wrong one.
Fix
Step 1: Send OTP
POST /api/auth/email-otp/send-verification-otp
Body:
{
"email": "user@example.com",
"type": "sign-in"
}
typeis REQUIRED. Valid values:"sign-in","email-verification","forget-password"- Returns
200 OKif successful - The OTP is sent to the email address
Step 2: Verify OTP and create session
POST /api/auth/sign-in/email-otp
Body:
{
"email": "user@example.com",
"otp": "123456"
}
- The OTP is the 6-digit code the user received via email
- Returns a session token on success
- Do NOT pass
typein step 2 — this endpoint expects only email + otp
Test Pattern (curl):
# Terminal 1: Run your dev server
pnpm dev
# Terminal 2: Send OTP (new tab with Cmd+T on macOS)
curl -X POST http://localhost:3000/api/auth/email-otp/send-verification-otp \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","type":"sign-in"}'
# Check email/console for OTP code (e.g., 123456)
# Then verify OTP
curl -X POST http://localhost:3000/api/auth/sign-in/email-otp \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","otp":"123456"}'
Pitfall 5: Two terminals required for curl-based testing
Symptom
Curl command typed into the same terminal where pnpm dev is running, text appears but never executes.
Cause
The dev server occupies the terminal and consumes input. Text you type appears but doesn't execute. Pressing Ctrl+C kills the dev server instead of stopping curl.
Fix
Always use two terminals:
-
Terminal 1 (left alone):
pnpm dev # Output: "Ready in Xms" — stop here, don't type anything -
Terminal 2 (your curl commands):
# On macOS: Cmd+T to open a new tab # On Linux: Open a fresh terminal window curl -X POST http://localhost:3000/api/auth/email-otp/send-verification-otp \ -H "Content-Type: application/json" \ -d '{"email":"test@example.com","type":"sign-in"}'
If you accidentally type in Terminal 1 and want to get back to the dev server, kill it gracefully:
- Terminal 1: Press Ctrl+C once (stops the dev server)
- Terminal 1: Restart with
pnpm dev
Pitfall 6: Better Auth twoFactor plugin requires password by default
Symptom
400 INVALID_PASSWORD
when calling /api/auth/sign-in/mfa/totp/enable even though the user never set a password.
Cause
When you disable email+password authentication (emailAndPassword: { enabled: false }), users have no password. The twoFactor plugin's enable() endpoint requires password verification by default, so passwordless accounts fail.
Fix
Pass allowPasswordless: true to the twoFactor plugin:
// auth.ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
database: db,
secret: process.env.BETTER_AUTH_SECRET,
emailAndPassword: {
enabled: false, // No password auth
},
plugins: [
twoFactor({
allowPasswordless: true, // Allow 2FA enrollment without password
}),
],
});
Important: allowPasswordless: true only skips the password check. The user still validates their TOTP code. Do NOT use skipVerificationOnEnable: true — that skips the entire TOTP verification step, allowing users to enable 2FA without proving they can scan the QR code, which is a security regression.
Pitfall 7: Better Auth 1.6.x twoFactor table schema requires verified column
Symptom
500 field "verified" does not exist
during 2FA enrollment, even though you have all other required columns.
Cause
Better Auth 1.6.x enforces a verified boolean column on the twoFactor table. This column tracks whether the user has confirmed their TOTP setup. If missing, schema validation fails.
Fix
Update your Drizzle schema to include the verified column with a default value:
// schema.ts
import { pgTable, text, boolean } from "drizzle-orm/pg-core";
import { user } from "./user";
export const twoFactor = pgTable("twoFactor", {
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backupCodes").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
verified: boolean("verified").notNull().default(true),
});
Migration: If you have an existing twoFactor table without the verified column, add it:
ALTER TABLE "twoFactor" ADD COLUMN "verified" BOOLEAN NOT NULL DEFAULT true;
After the migration, all existing 2FA enrollments are marked verified: true by default (they were already verified when set up).
Bonus Patterns
Pattern: Localized Email Templates
Better Auth defaults to English email templates. For a French or multi-locale app, override the templates:
// auth.ts
import { Resend } from "resend";
import { betterAuth } from "better-auth";
const resend = new Resend(process.env.RESEND_API_KEY);
export const auth = betterAuth({
database: db,
emailVerification: {
sendVerificationEmail: async ({ user, token }, _) => {
const verificationUrl = `${process.env.APP_URL}/verify-email?token=${token}`;
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL!,
to: user.email,
subject: "Vérifiez votre adresse e-mail",
html: `
<p>Bonjour ${user.name},</p>
<p>Veuillez cliquer sur le lien ci-dessous pour vérifier votre adresse e-mail :</p>
<a href="${verificationUrl}">Vérifier l'e-mail</a>
<p>Ce lien expire dans 1 heure.</p>
`,
});
},
},
});
Repeat this pattern for forgetPasswordEmail, sendMagicLink, and other email triggers. This ensures non-English users don't see phishing-like English text.
Pattern: Audit Log Every OTP/Magic-Link Send
Log email sends to your audit_logs table for compliance and debugging:
// auth.ts using databaseHooks
import { betterAuth } from "better-auth";
export const auth = betterAuth({
database: db,
databaseHooks: {
emailVerification: {
create: {
after: async (data) => {
// Log after verification email is created
await db.insert(auditLogs).values({
userId: data.userId,
action: "EMAIL_VERIFICATION_SENT",
timestamp: new Date(),
metadata: { email: data.email },
});
},
},
},
},
});
This creates a full audit trail of when emails were sent, to whom, and why — useful for debugging delivery issues and meeting compliance requirements.
Pattern: DEV-ONLY OTP Console Log
In development, print the OTP code to the console so you don't wait for email:
// auth.ts
import { betterAuth } from "better-auth";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
export const auth = betterAuth({
emailOTP: {
sendVerificationOTP: async ({ email, otp }, _) => {
// DEV: Log OTP to console
if (process.env.NODE_ENV === "development") {
console.log(`[DEV OTP] ${email}: ${otp}`);
}
// PROD: Send via Resend
if (process.env.NODE_ENV === "production") {
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL!,
to: email,
subject: "Votre code de connexion",
html: `<p>Votre code : <strong>${otp}</strong></p>`,
});
}
},
},
});
Pattern: Email Enumeration Prevention Test
Attackers can abuse sign-in endpoints to discover registered emails. Ensure your endpoints return the same response for known and unknown emails:
# Test with unknown email
curl -X POST http://localhost:3000/api/auth/email-otp/send-verification-otp \
-H "Content-Type: application/json" \
-d '{"email":"unknown@example.com","type":"sign-in"}' \
-w "\n%{http_code}\n"
# Test with known email
curl -X POST http://localhost:3000/api/auth/email-otp/send-verification-otp \
-H "Content-Type: application/json" \
-d '{"email":"registered@example.com","type":"sign-in"}' \
-w "\n%{http_code}\n"
Both should return 200 OK (or both 400). If one returns 200 and the other 404 or 422, your endpoint leaks email addresses. Better Auth's defaults are safe, but if you customize the endpoint, ensure timing and response are identical.
Pattern: Verify DNS Records Before Going Live
Before sending production email, confirm all records are in place:
#!/bin/bash
DOMAIN="example.com"
echo "Checking DKIM..."
dig TXT resend._domainkey.$DOMAIN +short
echo "Checking SPF on send subdomain..."
dig TXT send.$DOMAIN +short
echo "Checking DMARC at root..."
dig TXT _dmarc.$DOMAIN +short
All three should return non-empty records. If any are blank, Resend cannot authenticate your mail, and delivery will fail or be rejected.
Pattern: DMARC Progression
Email authentication is a trust ramp:
-
Week 1:
p=none— Monitor, don't block anythingv=DMARC1; p=none; rua=mailto:admin@example.com -
Week 2–4:
p=quarantine— Suspicious mail goes to spam, not trashv=DMARC1; p=quarantine; rua=mailto:admin@example.com -
Week 5+:
p=reject— Reject any mail failing DMARCv=DMARC1; p=reject; rua=mailto:admin@example.com
Check DMARC reports weekly (via the rua email) to ensure legitimate mail passes before tightening policy.
Quick Reference: Common Error Codes
| Error | Cause | Fix |
|---|---|---|
403 not authorized to send from X | FROM domain not verified or API key scoped to different domain | Check RESEND_FROM_EMAIL, verify domain in Resend dashboard, check API key scope |
500 field "verified" does not exist | Drizzle schema missing verified column on twoFactor table | Add verified: boolean().notNull().default(true) to schema |
400 INVALID_PASSWORD | User has no password, twoFactor plugin requires it | Add allowPasswordless: true to twoFactor config |
400 [body.otp] Invalid input | Called /send-verification-otp instead of /sign-in/email-otp | Use correct endpoint for your step |
500 DNS validation failed | DNS record missing or in wrong location | Use dig to verify records are on correct host (root vs subdomain) |
Resources
- Resend Docs: https://resend.com/docs
- Better Auth Docs: https://www.better-auth.com/docs
- Better Auth Plugins: https://www.better-auth.com/docs/plugins
- DNS Troubleshooting: Use
dig(macOS/Linux) ornslookup(Windows) to inspect records
