Formspree is a nice shortcut when you want form handling without running your own backend. I’ve used it for contact forms, waitlists, and simple lead capture. It removes a lot of backend work, but it does not remove frontend security work. If you collect user input and then render it anywhere in your app, you can still create an XSS bug just as easily as with a custom form handler.
That’s the part people miss.
Formspree receives the submission, but your page still decides how to display success messages, previews, validation errors, admin dashboards, confirmation screens, and any echoed-back form data. XSS around Formspree usually comes from the code around the form, not the POST target itself.
Where XSS happens with Formspree
The common pattern looks like this:
- User types data into a form.
- Form submits to Formspree.
- Your frontend reads values from inputs, query params, local storage, or Formspree responses.
- Your code injects that data into the DOM.
If step 4 uses unsafe DOM APIs, you’ve got a problem.
A typical example is a custom success screen:
<form id="contact-form" action="https://formspree.io/f/yourFormId" method="POST">
<label>
Name
<input type="text" name="name" id="name">
</label>
<label>
Message
<textarea name="message" id="message"></textarea>
</label>
<button type="submit">Send</button>
</form>
<div id="result"></div>
<script>
const form = document.getElementById('contact-form');
const result = document.getElementById('result');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const name = formData.get('name');
const response = await fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'Accept': 'application/json'
}
});
if (response.ok) {
result.innerHTML = `<p>Thanks, ${name}! We got your message.</p>`;
form.reset();
}
});
</script>
That innerHTML line is the issue. If name contains something like:
<img src=x onerror=alert(1)>
your “friendly” thank-you message becomes script execution.
The safe fix: treat all form input as untrusted
If user input goes into the DOM, prefer textContent, createTextNode, or framework-safe templating.
Here’s the same example done safely:
<form id="contact-form" action="https://formspree.io/f/yourFormId" method="POST">
<label>
Name
<input type="text" name="name" id="name">
</label>
<label>
Message
<textarea name="message" id="message"></textarea>
</label>
<button type="submit">Send</button>
</form>
<div id="result"></div>
<script>
const form = document.getElementById('contact-form');
const result = document.getElementById('result');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const name = formData.get('name');
const response = await fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'Accept': 'application/json'
}
});
if (response.ok) {
result.textContent = `Thanks, ${name}! We got your message.`;
form.reset();
} else {
result.textContent = 'Sorry, something went wrong.';
}
});
</script>
No HTML parsing means no HTML execution.
Dangerous patterns I keep seeing
1. Reflecting query parameters after redirect
A lot of teams redirect to a thank-you page and include form data in the URL:
https://example.com/thanks?name=<script>alert(1)</script>
Then they render it like this:
const params = new URLSearchParams(window.location.search);
document.getElementById('welcome').innerHTML =
`Thanks for your submission, ${params.get('name')}`;
That’s classic reflected XSS.
Safe version:
const params = new URLSearchParams(window.location.search);
document.getElementById('welcome').textContent =
`Thanks for your submission, ${params.get('name') || 'friend'}`;
Better version: don’t put user input in the URL unless you really need it.
2. Building HTML previews from form data
I see this in “live preview” widgets:
preview.innerHTML = `
<h3>${formData.get('subject')}</h3>
<p>${formData.get('message')}</p>
`;
That’s unsafe for both fields.
Safer approach:
preview.replaceChildren();
const h3 = document.createElement('h3');
h3.textContent = formData.get('subject') || '';
const p = document.createElement('p');
p.textContent = formData.get('message') || '';
preview.append(h3, p);
3. Storing submitted values and rendering later
Sometimes people cache draft values in localStorage:
localStorage.setItem('draftMessage', messageInput.value);
savedDraft.innerHTML = localStorage.getItem('draftMessage');
Still XSS. Storage doesn’t make it trusted.
Use:
savedDraft.textContent = localStorage.getItem('draftMessage') || '';
What about server-side rendering?
If you pull Formspree submission data into your own dashboard or status page, output encoding matters there too.
For example, in Node with a template engine, this is generally safe if the engine escapes by default:
res.render('submission', {
name: submission.name,
message: submission.message
});
But this is where people shoot themselves in the foot:
<p><%- submission.message %></p>
Unescaped output tags are an XSS footgun.
Use escaped output:
<p><%= submission.message %></p>
Same story in every framework: use the default escaping path unless you have a very good reason not to.
If you really must allow limited HTML
Most contact forms do not need HTML. Escape everything and move on.
If you’re building something more like user-generated content and want to allow a tiny subset of tags, sanitize before rendering. Sanitization is harder than developers think, and I’m pretty opinionated here: if you can avoid rendering user HTML, avoid it.
Client-side example with a sanitizer:
<div id="message-preview"></div>
<script type="module">
import DOMPurify from '/js/purify.es.mjs';
const rawMessage = '<b>Hello</b> <img src=x onerror=alert(1)>';
const clean = DOMPurify.sanitize(rawMessage, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: []
});
document.getElementById('message-preview').innerHTML = clean;
</script>
If you go this route, sanitize as close to rendering as possible, and keep the allowlist tiny.
Validate input, but don’t confuse that with XSS protection
Validation helps with abuse and garbage input. It does not replace output encoding.
You should still validate fields before sending to Formspree:
function validateForm({ name, email, message }) {
const errors = [];
if (!name || name.length > 100) {
errors.push('Name is required and must be under 100 characters.');
}
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
errors.push('Enter a valid email address.');
}
if (!message || message.length > 5000) {
errors.push('Message is required and must be under 5000 characters.');
}
return errors;
}
And render those errors safely too:
const errorList = document.getElementById('errors');
function showErrors(errors) {
errorList.replaceChildren();
for (const error of errors) {
const li = document.createElement('li');
li.textContent = error;
errorList.appendChild(li);
}
}
Even if the error strings are “internal,” building the habit of avoiding innerHTML pays off.
A safer Formspree integration pattern
Here’s a complete example that does the boring, safe thing:
<form id="contact-form" action="https://formspree.io/f/yourFormId" method="POST" novalidate>
<label>
Name
<input type="text" name="name" id="name" maxlength="100" required>
</label>
<label>
Email
<input type="email" name="email" id="email" maxlength="255" required>
</label>
<label>
Message
<textarea name="message" id="message" maxlength="5000" required></textarea>
</label>
<button type="submit">Send</button>
</form>
<ul id="errors"></ul>
<p id="status"></p>
<script>
const form = document.getElementById('contact-form');
const errorsEl = document.getElementById('errors');
const statusEl = document.getElementById('status');
function showErrors(errors) {
errorsEl.replaceChildren();
for (const error of errors) {
const li = document.createElement('li');
li.textContent = error;
errorsEl.appendChild(li);
}
}
function validateForm(data) {
const errors = [];
if (!data.name || data.name.length > 100) {
errors.push('Name is required and must be under 100 characters.');
}
if (!data.email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(data.email)) {
errors.push('Valid email is required.');
}
if (!data.message || data.message.length > 5000) {
errors.push('Message is required and must be under 5000 characters.');
}
return errors;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
statusEl.textContent = '';
showErrors([]);
const data = {
name: form.name.value.trim(),
email: form.email.value.trim(),
message: form.message.value.trim()
};
const errors = validateForm(data);
if (errors.length) {
showErrors(errors);
return;
}
const formData = new FormData(form);
try {
const response = await fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'Accept': 'application/json'
}
});
if (response.ok) {
statusEl.textContent = `Thanks, ${data.name}. Your message was sent.`;
form.reset();
} else {
statusEl.textContent = 'Submission failed. Please try again.';
}
} catch {
statusEl.textContent = 'Network error. Please try again.';
}
});
</script>
Add CSP so mistakes are less catastrophic
CSP won’t fix unsafe DOM insertion, but it can reduce blast radius. If someone accidentally introduces an innerHTML bug later, a good CSP can stop inline script execution and make some payloads fail.
A practical baseline looks like this:
Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self' https://formspree.io;
If your site submits directly to Formspree and uses fetch, you may also need:
Content-Security-Policy:
default-src 'self';
script-src 'self';
connect-src 'self' https://formspree.io;
form-action 'self' https://formspree.io;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
For deeper CSP implementation guidance, see https://csp-guide.com.
A short checklist for Formspree XSS prevention
When I review Formspree integrations, I check these first:
- No
innerHTMLwith user-controlled values - No reflecting form fields from query params into HTML
- No unsafe preview widgets
- No unescaped template output in admin or thank-you pages
- Validation for length and format
- Sanitization only if HTML is genuinely required
- CSP with
form-actionand restrictivescript-src
Formspree is fine. The risky part is the code developers wrap around it. If you treat every submitted field as hostile until safely encoded for its output context, you avoid nearly all of the XSS trouble people blame on “the form service.”