Back to Blog
Dec 10, 2025 12 min read FormShield Team

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.

sveltekit security forms tutorial
Hand-drawn illustration of a shield protecting a web form with validation checkmarks

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.

Stop fighting spam by hand

One API call. IP, email, content & behavior signals in a single intelligence platform. Start free, no credit card required.