Resend Email Security Guide for Vibe Coders

Share

TL;DR

Resend makes sending emails easy, but email APIs can be abused. Keep your API key server-side only. Validate and sanitize all user input before including it in emails. Rate limit email sending per user. Don't let users control the "from" address. Verify domain ownership and set up SPF/DKIM/DMARC to prevent spoofing.

Why Resend Security Matters for Vibe Coding

Resend provides a simple API for transactional emails. When AI tools generate email code, they often create working implementations but miss abuse prevention patterns. An exposed API key or unvalidated input can lead to spam, phishing, or account suspension.

API Key Management

# .env.local (never commit)
RESEND_API_KEY=re_xxxxxxxxxxxxx

Never Expose Your API Key: Your Resend API key can send emails from your verified domains. If exposed, attackers can send spam or phishing emails that appear to come from you, damaging your domain reputation and potentially getting you blacklisted.

import { Resend } from 'resend';

// Server-side only
const resend = new Resend(process.env.RESEND_API_KEY);

// Verify key exists
if (!process.env.RESEND_API_KEY) {
  throw new Error('RESEND_API_KEY is not configured');
}

Input Validation

Never trust user input in emails:

import { z } from 'zod';
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

// Validate email addresses
const ContactFormSchema = z.object({
  email: z.string().email().max(254),
  name: z.string().min(1).max(100),
  message: z.string().min(1).max(5000),
});

export async function POST(request: Request) {
  const body = await request.json();
  const result = ContactFormSchema.safeParse(body);

  if (!result.success) {
    return Response.json({ error: 'Invalid input' }, { status: 400 });
  }

  const { email, name, message } = result.data;

  // Sanitize for HTML email
  const sanitizedMessage = escapeHtml(message);
  const sanitizedName = escapeHtml(name);

  await resend.emails.send({
    from: 'Contact Form <noreply@yourdomain.com>', // Fixed sender
    to: 'support@yourdomain.com', // Fixed recipient
    replyTo: email, // User's email for reply
    subject: `Contact from ${sanitizedName}`,
    html: `<p>From: ${sanitizedName} (${email})</p><p>${sanitizedMessage}</p>`,
  });

  return Response.json({ success: true });
}

function escapeHtml(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

Preventing Email Injection

// DANGEROUS: User controls "to" address
await resend.emails.send({
  from: 'noreply@yourdomain.com',
  to: userProvidedEmail, // Could send to anyone!
  subject: 'Hello',
  html: content,
});

// SAFE: Only send to authenticated user's verified email
await resend.emails.send({
  from: 'noreply@yourdomain.com',
  to: session.user.email, // From your auth system
  subject: 'Your account update',
  html: content,
});

// DANGEROUS: User controls headers
await resend.emails.send({
  from: userProvidedFrom, // Spoofing!
  to: 'admin@yourdomain.com',
  subject: userProvidedSubject, // Could include newlines for header injection
  html: content,
});

// SAFE: Fixed sender, sanitized subject
await resend.emails.send({
  from: 'noreply@yourdomain.com',
  to: 'admin@yourdomain.com',
  subject: sanitizeSubject(userInput),
  html: sanitizedContent,
});

function sanitizeSubject(subject: string): string {
  // Remove newlines and limit length
  return subject.replace(/[\r\n]/g, ' ').substring(0, 200);
}

Rate Limiting Email Sending

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const emailRatelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '1 h'), // 5 emails per hour
});

export async function sendPasswordReset(userId: string, email: string) {
  // Rate limit per user
  const { success } = await emailRatelimit.limit(`email:${userId}`);

  if (!success) {
    throw new Error('Too many emails requested. Please try again later.');
  }

  // Also rate limit per email address to prevent enumeration
  const { success: emailSuccess } = await emailRatelimit.limit(`email:${email}`);

  if (!emailSuccess) {
    // Don't reveal if email exists - just silently succeed
    return { success: true };
  }

  await resend.emails.send({
    from: 'noreply@yourdomain.com',
    to: email,
    subject: 'Password Reset',
    html: generateResetEmail(userId),
  });
}

Domain Security

Configure these DNS records for your sending domain:

# SPF - Specify who can send email for your domain
TXT  @  "v=spf1 include:resend.com ~all"

# DKIM - Resend provides this record
# Add the DKIM record from your Resend dashboard

# DMARC - Policy for handling failed authentication
TXT  _dmarc  "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"

Webhook Security

import crypto from 'crypto';

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('svix-signature');
  const timestamp = request.headers.get('svix-timestamp');

  if (!signature || !timestamp) {
    return Response.json({ error: 'Missing headers' }, { status: 401 });
  }

  // Verify webhook signature
  const signedContent = `${timestamp}.${body}`;
  const expectedSignature = crypto
    .createHmac('sha256', process.env.RESEND_WEBHOOK_SECRET!)
    .update(signedContent)
    .digest('base64');

  if (!crypto.timingSafeEqual(
    Buffer.from(signature.split(',')[1].split(' ')[1]),
    Buffer.from(expectedSignature)
  )) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 });
  }

  // Process webhook
  const event = JSON.parse(body);

  switch (event.type) {
    case 'email.delivered':
      await logDelivery(event.data);
      break;
    case 'email.bounced':
      await handleBounce(event.data);
      break;
  }

  return Response.json({ success: true });
}

Resend Security Checklist

  • API key stored in environment variable, never in code
  • All email sending happens server-side only
  • "From" address is fixed, not user-controlled
  • Email addresses validated with proper schema
  • User input sanitized before including in emails
  • Subject lines sanitized (no newlines)
  • Rate limiting implemented per user and per email
  • SPF, DKIM, and DMARC configured for domain
  • Webhook signatures verified before processing
  • Bounce handling implemented to protect reputation

Can users send emails to anyone?

No. Only send to verified recipients (authenticated users, confirmed subscribers). Never let users specify arbitrary "to" addresses. For contact forms, send to your own address with the user's email as replyTo.

What if my API key is exposed?

Immediately rotate the key in your Resend dashboard. Check your sending logs for any unauthorized emails. Monitor for complaints or bounces that could indicate abuse.

Should I include user content in emails?

Be cautious. Always sanitize/escape HTML in user content. Consider using plain text for user-provided content. Never include user content in email headers.

What CheckYourVibe Detects

  • API keys exposed in client-side code
  • User-controlled "from" addresses
  • Missing input validation on email parameters
  • Unsanitized user content in email bodies
  • Missing rate limiting on email endpoints

Run npx checkyourvibe scan to catch these issues before they reach production.

Scan Your Resend Integration

Find exposed API keys, email injection vulnerabilities, and missing rate limits before they cause problems.

Start Free Scan
Tool & Platform Guides

Resend Email Security Guide for Vibe Coders