Retour au catalogue
DéveloppementPerso

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.

Next.jsAuthEmail
Chemin
~/.claude/skills/resend-better-auth
Modifié
26 avril 2026 à 09:38

Skill 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:

TypeHostValueNotes
TXTresend._domainkey.example.com(DKIM key from Resend dashboard)Root domain — resend._domainkey prefix
MXsend.example.comfeedback-smtp.eu-west-1.amazonses.com priority 10Subdomain send, NOT root
TXTsend.example.comv=spf1 include:amazonses.com ~allSubdomain send, NOT root
TXT_dmarc.example.comv=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:

  1. Step 1: Send the OTP to the user's email
  2. 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"
}
  • type is REQUIRED. Valid values: "sign-in", "email-verification", "forget-password"
  • Returns 200 OK if 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 type in 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:

  1. Terminal 1 (left alone):

    pnpm dev
    # Output: "Ready in Xms" — stop here, don't type anything
    
  2. 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:

  1. Week 1: p=none — Monitor, don't block anything

    v=DMARC1; p=none; rua=mailto:admin@example.com
    
  2. Week 2–4: p=quarantine — Suspicious mail goes to spam, not trash

    v=DMARC1; p=quarantine; rua=mailto:admin@example.com
    
  3. Week 5+: p=reject — Reject any mail failing DMARC

    v=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

ErrorCauseFix
403 not authorized to send from XFROM domain not verified or API key scoped to different domainCheck RESEND_FROM_EMAIL, verify domain in Resend dashboard, check API key scope
500 field "verified" does not existDrizzle schema missing verified column on twoFactor tableAdd verified: boolean().notNull().default(true) to schema
400 INVALID_PASSWORDUser has no password, twoFactor plugin requires itAdd allowPasswordless: true to twoFactor config
400 [body.otp] Invalid inputCalled /send-verification-otp instead of /sign-in/email-otpUse correct endpoint for your step
500 DNS validation failedDNS record missing or in wrong locationUse dig to verify records are on correct host (root vs subdomain)

Resources