DEV Community

FourTwentyDev
FourTwentyDev

Posted on

Solving the Contact Form Security Dilemma in Static Sites and SPAs

When building static sites, JAMstack applications, or SPAs, implementing a secure contact form has always been a challenging task. As developers, we're faced with a fundamental dilemma: how do we send emails from client-side code without exposing sensitive credentials?

In this post, I'll explore the common approaches to this problem, their limitations, and introduce a solution we've built at FourTwenty Email Service that takes a fundamentally different approach to solving this security challenge.

The Contact Form Security Challenge

If you've ever built a static site or SPA with a contact form, you've likely encountered these challenges:

1. The Credentials Exposure Problem

Traditional email sending APIs require authentication credentials (API keys, usernames/passwords). Including these in client-side code means they're visible to anyone who inspects your site's source code, creating a significant security risk.

// ❌ DANGEROUS: Credentials exposed in client-side code
const sendEmail = async (formData) => {
  await fetch('https://some-email-api.com/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Basic YOUR_API_KEY_EXPOSED_TO_EVERYONE' // Security risk!
    },
    body: JSON.stringify({
      to: 'your@email.com',
      subject: 'Contact Form Submission',
      text: `From: ${formData.name}\nEmail: ${formData.email}\nMessage: ${formData.message}`
    })
  });
};
Enter fullscreen mode Exit fullscreen mode

2. Common Workarounds and Their Limitations

Server-Side Solutions

The most secure approach is to use a server-side function or API route that keeps credentials private:

// Client-side code
const sendEmail = async (formData) => {
  await fetch('/api/send-email', {
    method: 'POST',
    body: JSON.stringify(formData)
  });
};

// Server-side code (not exposed to clients)
app.post('/api/send-email', (req, res) => {
  // Use email service with safely stored credentials
});
Enter fullscreen mode Exit fullscreen mode

Limitations:

  • Requires a server or serverless function
  • Adds complexity to what should be a simple feature
  • Increases maintenance overhead

Form Submission Services

Services like Formspree, FormKeep, or Netlify Forms handle form submissions without requiring your own backend:

<form action="https://formspree.io/f/yourformid" method="POST">
  <!-- Form fields -->
</form>
Enter fullscreen mode Exit fullscreen mode

Limitations:

  • Limited customization
  • Often requires paid plans for more than basic usage
  • Dependency on third-party services
  • May not integrate well with your application's UX

A Different Approach: Recipient-Bound API Keys

After facing these challenges repeatedly in our own projects, we built FourTwenty Email Service with a fundamentally different approach: recipient-bound API keys.

How It Works

  1. When you create an API key, you specify which email address will receive messages sent with that key
  2. The recipient is stored server-side and associated with the API key
  3. Your API calls only need to specify the content of the email
  4. The server enforces that emails can only be sent to the pre-defined recipient

This approach solves the security dilemma by making it safe to use API keys in client-side code, since exposing the key doesn't allow sending to arbitrary recipients.

// ✅ SAFE for client-side code
const sendContactForm = async (formData) => {
  await fetch('https://api.fourtwenty.dev/email/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': 'YOUR_API_KEY' // This key can ONLY send to your predefined email
    },
    body: JSON.stringify({
      subject: 'Contact Form Submission',
      text: `From: ${formData.name}\nEmail: ${formData.email}\nMessage: ${formData.message}`
    })
  });
};
Enter fullscreen mode Exit fullscreen mode

Enhanced Security Features

To further protect API keys used in client-side code, we've added two additional security features:

1. Custom Rate Limiting

You can configure both global and per-IP rate limits for each API key:

  • Set maximum requests within custom time windows (5min to 24h)
  • Apply different limits globally and per-IP address
  • Prevent abuse even if your API key is exposed

2. Geoblocking

If your site only serves specific regions, you can restrict API key usage by country:

  • Choose between allowlist mode (only specified countries can use the key) or blocklist mode (specified countries are blocked)
  • Protect your forms from spam originating from regions you don't serve
  • Add another layer of security for client-side usage

Implementation with Our NPM Package

We've recently released an official NPM package that makes implementation even easier:

npm install @fourtwentydev/mailservice
Enter fullscreen mode Exit fullscreen mode

Simple Function-Based API

import { sendEmail } from '@fourtwentydev/mailservice';

// In your form submission handler
const handleSubmit = async (event) => {
  event.preventDefault();
  const formData = new FormData(event.target);

  try {
    await sendEmail('YOUR_API_KEY', {
      subject: 'Contact Form Submission',
      text: `Name: ${formData.get('name')}\nEmail: ${formData.get('email')}\nMessage: ${formData.get('message')}`
    });

    // Show success message
  } catch (error) {
    // Handle error
    console.error('Failed to send email:', error.message);
  }
};
Enter fullscreen mode Exit fullscreen mode

Advanced Class-Based Client

For more control, you can use the class-based client with configuration options:

import { FTMailClient } from '@fourtwentydev/mailservice';

// Create a client with custom options
const client = new FTMailClient('YOUR_API_KEY', {
  retry: {
    enabled: true,
    maxRetries: 3,
    strategy: 'exponential',
    initialDelay: 1000,
  },
  timeout: 15000, // 15 seconds
});

// In your form submission handler
const handleSubmit = async (event) => {
  event.preventDefault();
  const formData = new FormData(event.target);

  try {
    await client.sendEmail({
      subject: 'Contact Form Submission',
      text: `Name: ${formData.get('name')}\nEmail: ${formData.get('email')}\nMessage: ${formData.get('message')}`
    });

    // Show success message
  } catch (error) {
    // Handle error based on error.code
    if (error.code === 'rate_limited') {
      // Show rate limit message
    } else {
      // Show general error message
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Best Practices for Efficient Usage

1. Implement Client-Side Validation

Always validate form inputs before submission to improve user experience and reduce unnecessary API calls:

const validateForm = (formData) => {
  const errors = {};

  if (!formData.get('email') || !/^\S+@\S+\.\S+$/.test(formData.get('email'))) {
    errors.email = 'Valid email is required';
  }

  if (!formData.get('message') || formData.get('message').trim().length < 10) {
    errors.message = 'Message must be at least 10 characters';
  }

  return Object.keys(errors).length > 0 ? errors : null;
};

const handleSubmit = async (event) => {
  event.preventDefault();
  const formData = new FormData(event.target);

  const errors = validateForm(formData);
  if (errors) {
    // Display errors to user
    return;
  }

  // Proceed with sending email
};
Enter fullscreen mode Exit fullscreen mode

2. Implement Proper Error Handling

Our client provides detailed error information to help you handle different scenarios:

try {
  await client.sendEmail({
    subject: 'Contact Form',
    text: 'Message content'
  });

  showSuccessMessage();
} catch (error) {
  switch (error.code) {
    case 'rate_limited':
      showMessage('Too many requests. Please try again later.');
      break;
    case 'geoblocked':
      showMessage('Service not available in your region.');
      break;
    case 'invalid_request':
      showMessage('Please check your form inputs and try again.');
      break;
    default:
      showMessage('An error occurred. Please try again later.');
  }

  console.error('Error details:', error);
}
Enter fullscreen mode Exit fullscreen mode

3. Add CAPTCHA for High-Traffic Sites

For additional protection against spam, consider adding CAPTCHA to your forms:

// Using reCAPTCHA v3
const handleSubmit = async (event) => {
  event.preventDefault();

  try {
    // Get reCAPTCHA token
    const token = await grecaptcha.execute('your-site-key', {action: 'submit'});

    // Include token in your form data
    const formData = new FormData(event.target);

    // Verify token server-side or use our verification endpoint
    // Then send email if verification passes

  } catch (error) {
    // Handle error
  }
};
Enter fullscreen mode Exit fullscreen mode

4. Use Loading States for Better UX

Always provide feedback to users during the submission process:

const ContactForm = () => {
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [result, setResult] = useState(null);

  const handleSubmit = async (event) => {
    event.preventDefault();
    setIsSubmitting(true);

    try {
      await sendEmail('YOUR_API_KEY', {
        // Email details
      });

      setResult({ success: true, message: 'Message sent successfully!' });
    } catch (error) {
      setResult({ success: false, message: 'Failed to send message.' });
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send Message'}
      </button>

      {result && (
        <div className={result.success ? 'success' : 'error'}>
          {result.message}
        </div>
      )}
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

Building secure contact forms for static sites and SPAs has traditionally forced developers to choose between security and simplicity. With the recipient-bound API key approach, you can now have both - the security of server-side solutions with the simplicity of client-side implementation.

FourTwenty Email Service is currently available with a free tier that includes 750 emails per month and 5 emails per hour. We're actively developing additional features including HTML email support (the client is ready, backend implementation coming soon).

I'd love to hear your thoughts on this approach and any questions you might have about implementing secure contact forms in your projects!


Have you faced challenges implementing contact forms in static sites? What solutions have you used? Let me know in the comments!

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (0)

AWS Q Developer image

Your AI Code Assistant

Automate your code reviews. Catch bugs before your coworkers. Fix security issues in your code. Built to handle large projects, Amazon Q Developer works alongside you from idea to production code.

Get started free in your IDE

👋 Kindness is contagious

If you found this article helpful, please give a ❤️ or share a friendly comment!

Got it