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}`
})
});
};
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
});
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>
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
- When you create an API key, you specify which email address will receive messages sent with that key
- The recipient is stored server-side and associated with the API key
- Your API calls only need to specify the content of the email
- 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}`
})
});
};
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
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);
}
};
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
}
}
};
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
};
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);
}
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
}
};
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>
);
};
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!
Top comments (0)