How to Secure Forms in SvelteKit: Complete Guide
Learn how to protect SvelteKit forms with CSRF tokens, Zod validation, rate limiting, and spam prevention. Practical code examples included.
You built a contact form in SvelteKit. It works great. Users fill it out, data hits your server, everyone’s happy.
Then the bots find it.
Suddenly you’re getting 500 submissions a day from “John Smith” at “test@test.com” trying to sell you SEO services. Or worse, someone’s hammering your endpoint trying to brute-force their way past your login form.
SvelteKit gives you solid foundations for form security. Form actions run server-side by default. There’s built-in CSRF protection. But foundations aren’t enough. You need layers. Let’s build them.
SvelteKit Form Actions: Your First Line of Defense
Before we get into security specifics, let’s establish what makes SvelteKit forms different from client-side form handling.
Form actions run entirely on the server. No JavaScript required on the client. This is already a security win because your validation logic never ships to the browser where attackers can inspect and bypass it.
Here’s a basic form action:
// src/routes/contact/+page.server.ts
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
const message = data.get('message');
if (!email || typeof email !== 'string') {
return fail(400, { error: 'Email is required' });
}
if (!message || typeof message !== 'string') {
return fail(400, { error: 'Message is required' });
}
// Process the submission
await saveMessage({ email, message });
return { success: true };
}
};
And the corresponding form:
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import type { ActionData } from './$types';
export let form: ActionData;
</script>
<form method="POST">
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<button type="submit">Send</button>
</form>
This works without any client-side JavaScript. The form submits, the page reloads with the result. Simple, resilient, accessible.
But it’s not secure enough for production.
CSRF Protection in SvelteKit
Cross-Site Request Forgery happens when an attacker tricks a user’s browser into submitting a form to your site. The user is logged in. Their cookies get sent automatically. Your server thinks it’s a legitimate request.
Good news: SvelteKit has built-in CSRF protection. It checks the Origin header on every form submission and rejects requests from different origins.
This protection is enabled by default. You can configure trusted origins in svelte.config.js:
// svelte.config.js
import adapter from '@sveltejs/adapter-auto';
const config = {
kit: {
adapter: adapter(),
csrf: {
checkOrigin: true,
trustedOrigins: [
'https://your-domain.com',
'https://checkout.stripe.com' // For payment callbacks
]
}
}
};
export default config;
The origin-based protection handles form submissions with standard content types. But if you’re accepting JSON requests that modify data, you need token-based protection too.
Here’s how to implement CSRF tokens for additional security:
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import crypto from 'crypto';
export const load: LayoutServerLoad = async ({ cookies }) => {
let csrfToken = cookies.get('csrf');
if (!csrfToken) {
csrfToken = crypto.randomBytes(32).toString('hex');
cookies.set('csrf', csrfToken, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'strict'
});
}
return { csrfToken };
};
Then validate it in your form actions:
// src/routes/contact/+page.server.ts
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const submittedToken = data.get('_csrf');
const storedToken = cookies.get('csrf');
if (!storedToken || submittedToken !== storedToken) {
return fail(403, { error: 'Invalid request' });
}
// Continue with form processing...
}
};
Include the token in your form:
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<form method="POST">
<input type="hidden" name="_csrf" value={data.csrfToken} />
<!-- rest of form -->
</form>
Server-Side Validation with Zod
Client-side validation improves UX. Server-side validation is what actually protects you. Never trust data from the client.
Zod is the go-to validation library for TypeScript projects. It’s type-safe, composable, and catches edge cases you’d miss with manual validation.
Here’s a proper contact form with Zod validation:
// src/lib/schemas/contact.ts
import { z } from 'zod';
export const contactSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Invalid email address')
.max(255, 'Email is too long'),
name: z
.string()
.min(1, 'Name is required')
.max(100, 'Name is too long')
.regex(/^[a-zA-Z\s'-]+$/, 'Name contains invalid characters'),
message: z
.string()
.min(10, 'Message must be at least 10 characters')
.max(5000, 'Message is too long')
});
export type ContactForm = z.infer<typeof contactSchema>;
Now use it in your action:
// src/routes/contact/+page.server.ts
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import { contactSchema } from '$lib/schemas/contact';
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const data = {
email: formData.get('email'),
name: formData.get('name'),
message: formData.get('message')
};
const result = contactSchema.safeParse(data);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
return fail(400, {
errors,
values: data // Return values so user doesn't lose input
});
}
// result.data is now typed and validated
await saveContact(result.data);
return { success: true };
}
};
Display errors in your form:
<script lang="ts">
import type { ActionData } from './$types';
export let form: ActionData;
</script>
<form method="POST">
<label>
Email
<input
type="email"
name="email"
value={form?.values?.email ?? ''}
/>
{#if form?.errors?.email}
<span class="error">{form.errors.email[0]}</span>
{/if}
</label>
<!-- Similar for other fields -->
<button type="submit">Send</button>
</form>
Superforms: The Better Way
If you’re building forms regularly, Superforms is worth the dependency. It handles validation, progressive enhancement, and error handling in a unified way.
npm i -D sveltekit-superforms zod
// src/routes/contact/+page.server.ts
import { superValidate, fail } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { contactSchema } from '$lib/schemas/contact';
export const load = async () => {
const form = await superValidate(zod(contactSchema));
return { form };
};
export const actions = {
default: async ({ request }) => {
const form = await superValidate(request, zod(contactSchema));
if (!form.valid) {
return fail(400, { form });
}
await saveContact(form.data);
return { form };
}
};
<script lang="ts">
import { superForm } from 'sveltekit-superforms';
import type { PageData } from './$types';
export let data: PageData;
const { form, errors, enhance } = superForm(data.form);
</script>
<form method="POST" use:enhance>
<label>
Email
<input type="email" name="email" bind:value={$form.email} />
{#if $errors.email}
<span class="error">{$errors.email}</span>
{/if}
</label>
<!-- Other fields -->
<button type="submit">Send</button>
</form>
The use:enhance directive adds progressive enhancement without losing the server-side-first approach. Forms still work without JavaScript.
Rate Limiting Your Form Actions
Without rate limiting, attackers can hammer your endpoints thousands of times per second. This burns server resources, fills your database with garbage, and might even cost you money if you’re paying for email sends or API calls.
sveltekit-rate-limiter is the standard solution:
npm i sveltekit-rate-limiter
// src/routes/contact/+page.server.ts
import { error, fail } from '@sveltejs/kit';
import { RateLimiter } from 'sveltekit-rate-limiter/server';
import type { Actions } from './$types';
const limiter = new RateLimiter({
IP: [10, 'h'], // 10 requests per hour per IP
IPUA: [5, 'm'] // 5 requests per minute per IP+UserAgent
});
export const actions: Actions = {
default: async (event) => {
if (await limiter.isLimited(event)) {
error(429, 'Too many requests. Please try again later.');
}
// Continue with form processing...
}
};
For more granular control, you can use different limits for different actions:
const loginLimiter = new RateLimiter({
IP: [5, 'h'], // Stricter for login
IPUA: [3, '15m']
});
const contactLimiter = new RateLimiter({
IP: [20, 'h'], // More lenient for contact forms
IPUA: [5, 'm']
});
export const actions: Actions = {
login: async (event) => {
if (await loginLimiter.isLimited(event)) {
error(429, 'Too many login attempts');
}
// ...
},
contact: async (event) => {
if (await contactLimiter.isLimited(event)) {
error(429, 'Please wait before sending another message');
}
// ...
}
};
For production apps with multiple server instances, you’ll want Redis-backed rate limiting. The library supports custom stores:
import { RateLimiter } from 'sveltekit-rate-limiter/server';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const limiter = new RateLimiter({
IP: [10, 'h']
}, {
store: {
get: (key) => redis.get(key),
set: (key, value, ttl) => redis.setex(key, ttl, value)
}
});
Spam Protection Techniques
Rate limiting stops automated floods. But sophisticated bots pace themselves. You need additional signals to catch them.
Honeypot Fields
Add a hidden field that humans won’t see but bots will fill:
<form method="POST">
<!-- Honeypot - hidden from humans -->
<div class="hidden" aria-hidden="true">
<label>
Website
<input
type="text"
name="website"
tabindex="-1"
autocomplete="off"
/>
</label>
</div>
<!-- Real fields -->
<label>
Email
<input type="email" name="email" required />
</label>
<!-- ... -->
</form>
<style>
.hidden {
position: absolute;
left: -9999px;
}
</style>
Check it server-side:
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
// If honeypot is filled, it's a bot
if (data.get('website')) {
// Return fake success to avoid revealing the trap
return { success: true };
}
// Continue with real validation...
}
};
Timing Analysis
Bots fill forms instantly. Humans take time to read and type. Track when the form loaded:
<script lang="ts">
const loadedAt = Date.now();
</script>
<form method="POST">
<input type="hidden" name="loadedAt" value={loadedAt} />
<!-- ... -->
</form>
Validate timing server-side:
const MIN_FORM_TIME = 3000; // 3 seconds
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const loadedAt = Number(data.get('loadedAt'));
const submitTime = Date.now() - loadedAt;
if (submitTime < MIN_FORM_TIME) {
// Too fast - likely a bot
return { success: true }; // Fake success
}
// Continue...
}
};
Content Analysis
Check for spam patterns in the submission content:
function isSpamContent(message: string): boolean {
const spamPatterns = [
/\b(viagra|casino|lottery|bitcoin)\b/i,
/https?:\/\/[^\s]+\.[^\s]+/g, // URLs
/\[url=/i, // BBCode
/<a\s+href/i // HTML links
];
for (const pattern of spamPatterns) {
if (pattern.test(message)) {
return true;
}
}
// Too many links
const urlCount = (message.match(/https?:\/\//g) || []).length;
if (urlCount > 2) {
return true;
}
return false;
}
Email Validation
Disposable email addresses are a spam signal:
const DISPOSABLE_DOMAINS = new Set([
'tempmail.com',
'guerrillamail.com',
'mailinator.com',
'10minutemail.com',
// Add more...
]);
function isDisposableEmail(email: string): boolean {
const domain = email.split('@')[1]?.toLowerCase();
return DISPOSABLE_DOMAINS.has(domain);
}
Putting It All Together
Here’s a production-ready contact form with all the security layers:
// src/routes/contact/+page.server.ts
import { fail, error } from '@sveltejs/kit';
import { RateLimiter } from 'sveltekit-rate-limiter/server';
import { z } from 'zod';
import type { Actions } from './$types';
const limiter = new RateLimiter({
IP: [10, 'h'],
IPUA: [3, 'm']
});
const contactSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100),
message: z.string().min(10).max(5000)
});
const MIN_FORM_TIME = 3000;
export const actions: Actions = {
default: async (event) => {
// 1. Rate limiting
if (await limiter.isLimited(event)) {
error(429, 'Too many requests');
}
const data = await event.request.formData();
// 2. CSRF check (in addition to SvelteKit's built-in)
const csrfToken = data.get('_csrf');
if (csrfToken !== event.cookies.get('csrf')) {
return fail(403, { error: 'Invalid request' });
}
// 3. Honeypot check
if (data.get('website')) {
return { success: true }; // Fake success
}
// 4. Timing check
const loadedAt = Number(data.get('loadedAt'));
if (Date.now() - loadedAt < MIN_FORM_TIME) {
return { success: true }; // Fake success
}
// 5. Schema validation
const result = contactSchema.safeParse({
email: data.get('email'),
name: data.get('name'),
message: data.get('message')
});
if (!result.success) {
return fail(400, {
errors: result.error.flatten().fieldErrors
});
}
// 6. Content checks
if (isSpamContent(result.data.message)) {
return { success: true }; // Fake success
}
if (isDisposableEmail(result.data.email)) {
return fail(400, {
error: 'Please use a non-disposable email address'
});
}
// 7. Process legitimate submission
await saveContact(result.data);
return { success: true };
}
};
That’s a lot of code to maintain. And we haven’t even covered IP reputation, VPN detection, or ML-based content analysis.
FormShield: Skip the Boilerplate
Building and maintaining form security is a grind. Spam patterns evolve. Disposable email domains multiply. IP reputation databases need constant updates. You could spend weeks on this and still miss edge cases.
FormShield handles all of this through a single API:
// src/routes/contact/+page.server.ts
import { checkForm } from '@formshield/sveltekit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async (event) => {
const data = await event.request.formData();
const result = await checkForm({
email: data.get('email') as string,
name: data.get('name') as string,
content: data.get('message') as string,
ip: event.getClientAddress(),
metadata: {
formLoadedAt: Number(data.get('loadedAt')),
honeypotField: data.get('website') as string
}
});
if (result.action === 'block') {
// Return fake success to avoid revealing detection
return { success: true };
}
if (result.action === 'review') {
// Flag for manual review
await saveForReview({
email: data.get('email'),
message: data.get('message'),
signals: result.signals
});
return { success: true };
}
// Process legitimate submission
await saveContact({
email: data.get('email'),
name: data.get('name'),
message: data.get('message')
});
return { success: true };
}
};
FormShield combines:
- IP intelligence - VPN detection, datacenter identification, threat scoring
- Email validation - Disposable detection, MX verification, domain age
- Content analysis - ML models trained on millions of spam submissions
- Behavioral signals - Timing patterns, bot fingerprinting
You get a spam score from 0-10 with detailed signal breakdowns. Configure your thresholds. Let the API handle the complexity.
The free tier gives you 1,000 requests per month to test. Enough to see how it performs on your real traffic.
Security Checklist
Before you ship:
- CSRF protection enabled (SvelteKit default + tokens for JSON)
- Server-side validation with Zod or similar
- Rate limiting on all form actions
- Honeypot fields for basic bot detection
- Timing analysis to catch instant submissions
- Content filtering for obvious spam patterns
- Disposable email detection
- Secure cookie configuration (httpOnly, secure, sameSite)
- Error messages that don’t reveal internal logic
And remember: always return fake success for detected spam. Real error messages just help attackers refine their approach.
Your forms are the front door to your application. Lock them properly.
Ready to stop spam without the complexity? Try FormShield free - 1,000 requests/month, no credit card required. Or see how it works.