Users and Security

Input Validation and Data Security


Learning Objectives

  • You understand the difference between client-side and server-side validation and why both matter.
  • You know how to validate user input on both the client and server.

So far, we’ve built authentication, protected routes, and implemented authorization. Now we address input validation — ensuring user data meets application requirements before processing or storage.

Without validation, applications are vulnerable to security issues, data corruption, and unexpected behavior. Consider a book rating application where a user could submit a rating of 100 instead of 1-5, create a book with an empty title, or inject malicious SQL code in a review field. Each represents a different type of vulnerability: business logic violations, data integrity issues, and security exploits. Input validation prevents all of these.

The principle is simple: never trust user input. Any data from users — whether from forms, API requests, or URL parameters — must be validated before use.

Client-side vs. Server-side validation

There are two places where validation can occur: the client (browser) and the server. Both are important, but they serve different purposes.

Client-side validation

Client-side validation happens in the browser before data is sent to the server. It is primarily for user experience. With client-side validation, users can get immediate feedback on their inputs, making forms easier and faster to use, while reducing unnecessary server requests.

HTML has built-in validation attributes like required, type, min, max, pattern, etc, to input elements. As an example, the following form has one input field for a number input. The number input only accepts numbers between 1 and 5.

Try entering an invalid value to the field and submitting the form. The browser will prevent submission and show error messages.

More complex validation functionality could be implemented with JavaScript (or with Svelte components, in our case), but even basic HTML validation covers many common cases.

Client-side validation can provide instant feedback and improve the user experience, but it is not secure. A malicious user can easily bypass client-side checks by disabling JavaScript, using browser developer tools to modify restrictions, or sending requests directly with tools like curl.

Server-side validation

Server-side validation happens on the server after receiving a request. Server-side validation is primarily for security. It ensures that only valid and safe data is processed and stored. Server-side validation protects against malicious users who may try to exploit vulnerabilities by sending unexpected or harmful data.

As an example, imagine that we have a controller that is used for processing the above form submission. The controller would need to validate the input data to ensure it meets the expected criteria.

const createRating = async (c) => {
  const { rating } = await c.req.json();

  const numRating = parseInt(rating);
  if (isNaN(numRating) || numRating < 1 || numRating > 5) {
    return c.json({ error: "Rating must be a number between 1 and 5" }, 400);
  }

  // ... create rating
};

With the validation functionality above, the server ensures that the rating is a number between 1 and 5.

Without valdation like the above, a malicious user could send a request with an invalid rating (e.g., 100 or “DROP TABLE ratings;”) and potentially cause harm to the application or database.

However, server-side validation has its limitations. It cannot provide instant feedback like client-side validation, as it requires a round trip to the server. Additionally, server-side validation can increase server load, as every request must be processed and validated.

Loading Exercise...

Database validation

Another layer of server-side validation — database is a server — is validation implemented in the database. This includes using constraints like NOT NULL, UNIQUE, CHECK, and foreign keys to enforce data integrity. As an example, to store book ratings with optional reviews, we could use the following book_ratings table:

CREATE TABLE book_ratings (
  id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id VARCHAR(255) NOT NULL REFERENCES users(id),
  book_id INTEGER NOT NULL REFERENCES books(id),
  rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
  review TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  UNIQUE(user_id, book_id)
);

The above example assumes that we’re using Better Auth’s VARCHAR(255) type for the user identifier.

The database table definition above includes several constraints:

  • NOT NULL constraints ensure that user_id, book_id, and rating cannot be null.
  • Foreign key constraints ensure that user_id and book_id reference valid entries in the users and books tables, respectively.
  • The CHECK constraint ensures that the rating is between 1 and 5.
  • The UNIQUE constraint ensures that a user can rate a book only once.

In addition, the types of the columns (e.g., INTEGER, TIMESTAMP, VARCHAR, TEXT) provide another layer of validation by ensuring that the data stored in each column is of the correct type.

Defence in depth

Validation should be implemented at multiple layers: client-side, server-side, and database. Each layer serves a different purpose and provides a different level of protection. This is a core principle of the “defense in depth” security strategy — multiple layers of defense provide better security than relying on a single layer.


Loading Exercise...

Schema-based validation with Zod

In the earlier example, we implemented validation for a rating manually. The function looked as follows:

const createRating = async (c) => {
  const { rating } = await c.req.json();

  const numRating = parseInt(rating);
  if (isNaN(numRating) || numRating < 1 || numRating > 5) {
    return c.json({ error: "Rating must be a number between 1 and 5" }, 400);
  }

  // ... create rating
};

This works, but it’s repetitive and error-prone. Every time we create a new endpoint, we need to write similar validation code. This can lead to inconsistencies and bugs.

This is where validation libraries come in. They provide a way to define validation rules in a declarative way, and they handle the validation logic for you. Here, we look into using Zod, which allows defining schemas for your data and using those for validation.

A schema is a blueprint that describes the structure and constraints of your data.

Installing Zod

To install, Zod, run the following command in the server-side project.

$ deno add jsr:@zod/zod@4.1.12

This adds Zod as a dependency to your project. After running the command, the deno.json should look as follows.

{
  "imports": {
    "@hono/hono": "jsr:@hono/hono@4.8.12",
    "@zod/zod": "jsr:@zod/zod@4.1.12",
    "better-auth": "npm:better-auth@1.3.27",
    "kysely-postgres-js": "npm:kysely-postgres-js@3.0.0",
    "postgres": "npm:postgres@3.4.7",
    "scrypt": "jsr:@denorg/scrypt@4.4.4"
  }
}

Defining schemas with Zod

Schemas in Zod are defined through Zod’s validation helpers. Here, we go over a few common examples. Create a file called zod-test.js to your server directory for testing these.

Validating primitives

Zod comes with a variety of validation primitives that can be used to validate data, ranging from strings to numbers and booleans.

The primitives are highlighted in the documentation of Zod at https://zod.dev/?id=primitives.

As an example, the following program shows simply how to validate whether an input is a string.

import { z } from "@zod/zod";

const validator = z.string();

let result = validator.safeParse("a string");
console.log(result);

result = validator.safeParse(123);
console.log(result);

Now, when you run the program with deno run zod-test.js, you should see the following output:

$ deno run zod-test.js
{ success: true, data: "a string" }
{ success: false, error: [Getter] }

Some primitives like string and number come with additional validation methods. For example, the string primitive has methods like min(length), max(length), email(), and url() that can be used to validate strings further.

The following example outlines a program that imports Zod, creates a validation rule for an email, and then validates two strings. The first string is not an email, while the second string is an email.

import { z } from "@zod/zod";

const validator = z.string().email();

let result = validator.safeParse("This is not an email");
console.log(result);

result = validator.safeParse("this-is-an@email.com");
console.log(result);
$ deno run zod-test.js
{ success: false, error: [Getter] }
{ success: true, data: "this-is-an@email.com" }
Loading Exercise...

Validating objects

When processing JSON data in web applications, the data is often structured as objects. Hence, validating objects is a common requirement. Zod provides functionality for defining validation rules for objects through the z.object() method.

In the following, we create a validator that validates an object. The object must contain an attribute email that needs to be an email address.

import { z } from "@zod/zod";

const validator = z.object({
  email: z.string().email(),
});

let result = validator.safeParse("this-is-an@email.com");
console.log(result);

result = validator.safeParse({ email: "this-is-an@email.com" });
console.log(result);
deno run zod-test.js
{ success: false, error: [Getter] }
{ success: true, data: { email: "this-is-an@email.com" } }

Object validation functionality is highlighted in Zod’s documentation at https://zod.dev/?id=objects.

Loading Exercise...

Retaining only relevant data

Zod comes with a feature where the validation retains only the relevant data. This is illustrated in the following example, where the object that we want to validate has an attribute garbage in addition to the attribute email. When we run the validation, the object in the data attribute of the validation result contains only the email.

import { z } from "@zod/zod";

const validator = z.object({
  email: z.string().email(),
});

let result = validator.safeParse({
  garbage: "not needed",
  email: "another@email.com",
});

console.log(result);
deno run zod-test.js
{ success: true, data: { email: "another@email.com" } }

This can help avoid working with unnecessary data and can also help avoid security issues where unexpected data is sent to the server.

Loading Exercise...

Custom validation error messages

It is also possible to define custom validation error messages. They are entered into the validation primitives as objects that contain an attribute message. The following shows an example of providing custom validation error messages to the application.

const validator = z.object({
  email: z.string().email({ message: "The email was not a valid email." }),
  yearOfBirth: z.coerce.number({
    message: "The year of birth was not a number.",
  })
    .min(1900, { message: "The year of birth cannot be smaller than 1900." })
    .max(2030, { message: "The year of birth cannot be larger than 2030." }),
});
Loading Exercise...

Zod Middleware for Hono

Hono provides a middleware for Zod that can be used to add validation functionality to routes.

Installing the middleware

To take the middleware into use, run the following command in the server-side project.

$ deno add jsr:@hono/zod-validator@0.7.4

After running the command, the deno.json should look as follows:

{
  "imports": {
    "@hono/hono": "jsr:@hono/hono@4.8.12",
    "@hono/zod-validator": "jsr:@hono/zod-validator@0.7.4",
    "@zod/zod": "jsr:@zod/zod@4.1.12",
    "better-auth": "npm:better-auth@1.3.27",
    "kysely-postgres-js": "npm:kysely-postgres-js@3.0.0",
    "postgres": "npm:postgres@3.4.7",
    "scrypt": "jsr:@denorg/scrypt@4.4.4"
  }
}

Using the middleware

After installing the middleware, we can import a zValidator function to the application.

import { zValidator } from "@hono/zod-validator";

The zValidator is a function that is given two arguments: the type of the data to be validated and the Zod validator to use when validating the data. When used with Hono, the value returned from calling zValidator is passed to a route, where it is used to validate the data in the request.

The example below shows a full application that can be used to validate emails using Hono, Zod, and Hono middleware for Zod. The application uses the zValidator middleware to validate JSON data in the request body, ensuring that the JSON document in the request body has a valid email address.

import { Hono } from "@hono/hono";
import { cors } from "@hono/hono/cors";
import { z } from "@zod/zod";
import { zValidator } from "@hono/zod-validator";

const app = new Hono();
app.use("/*", cors());

const emailValidator = z.object({
  email: z.string().email(),
});

app.post("/api/emails", zValidator("json", emailValidator), (c) => {
  const data = c.req.valid("json");
  return c.json(data);
});

export default app;

Note that now, we can the validated data through the c.req.valid("json") method. The method takes as an argument the type of the data that was validated, which in this case is JSON. This extracts the validated data from the request, and returns it into use.

Loading Exercise...

What the middleware expects

When using the middleware, the “Content-Type” header with the correct value must be added to the request. As an example, if JSON data is sent to the server, the value of the “Content-Type” header must be application/json. If the header is not present, the middleware will automatically respond with a validation error.

In the example below, we send a valid email with the content type set to application/json. The response from the server contains the email in the response body, and the status code indicates that the request was successful.

$ curl -v -X POST -H "Content-Type: application/json" -d '{"email":"valid@email.com"}' localhost:8000/api/emails
// ...
< HTTP/1.1 200 OK
// ...
{"email":"valid@email.com"}%

In the next example, below, the request is made with the correct data but without the “Content-Type” header.The response status is 400, indicating a bad request. In addition, the response shows that the request is not successful, and the error indicates an “invalid_type”.

$ curl -v -X POST -d '{"email":"valid@email.com"}' localhost:8000/api/emails
// ...
< HTTP/1.1 400 Bad Request
< access-control-allow-origin: *
< content-type: application/json; charset=UTF-8
< vary: Accept-Encoding
< content-length: 161
< date: Mon, 11 Nov 2024 12:40:58 GMT
<
* Connection #0 to host localhost left intact
{"success":false,"error":{"issues":[{"code":"invalid_type","expected":"string","received":"undefined","path":["email"],"message":"Required"}],"name":"ZodError"}}%

When we make a request to the server with invalid data but with the correct “Content-Type” header, the status code of the response is again 400. This time, the issue related to the email is present in the response, where the message has the string “Invalid email”.

$ curl -v -X POST -H "Content-Type: application/json" -d '{"email":"invalid"}' localhost:8000/api/emails
// ...
< HTTP/1.1 400 Bad Request
// ...
{"success":false,"error":{"issues":[{"validation":"email","code":"invalid_string","message":"Invalid email","path":["email"]}],"name":"ZodError"}}%
Loading Exercise...

Validation patterns

Storing validation schemas in a separate file

When using validation, it is common to store the validation schemas in a separate file. This way, the validation schemas can be reused across multiple routes and controllers. For example, we could create a file called validators.js in the server directory and store the email validator there.

import { z } from "@zod/zod";

export const emailValidator = z.object({
  email: z.string().email(),
});

Then, in the application file, we could import the validator and use it in the route.

// ...
import * as validators from "./validators.js";
// ...

app.post("/api/emails", zValidator("json", validators.emailValidator), (c) => {
  const data = c.req.valid("json");
  return c.json(data);
});
// ...

This way, the validation schemas are organized and can be easily reused across the application.

Using validation schemas in controllers

Further, when using layered architecture with separate controller files, it is common to use the validation schemas in the controller files. This way, the controller can focus on the business logic, while the validation is handled by the middleware.

As an example, for the above email validation, we would have a file called emailController.js. First, without validation, the controller would look as follows.

const createEmail = async (c) => {
  const data = await c.req.json();
  // do something with email

  return c.json(data);
};

export { createEmail };

When using validation, each function with validation exposed by the controller would be a list, where the first item is the validation middleware, and the second item is the actual controller function. As an example, with validation, the above could be written as follows.

import * as validators from "./validators.js";
import { zValidator } from "@hono/zod-validator";

const createEmail = [
  zValidator("json", validators.emailValidator),
  async (c) => {
    const data = c.req.valid("json");
    // do something with email

    return c.json(data);
  },
];

export { createEmail };

Then, in the application file when wiring the routes, we would destructure the controller function from the list with the destructuring operator .... As an example, the application file would look as follows.

// ...
import * as emailController from "./emailController.js";
// ...

app.post("/api/emails", ...emailController.createEmail);

// ...

This way, the controller can focus on the business logic, while the validation is handled by the middleware.

Loading Exercise...

Showing validation errors on the client

Validation errors from Zod middleware are returned in a structured format. The response contains a success attribute that is a boolean that indicates whether the validation was successful or not. If the validation was not successful, the response contains an error attribute that contains the validation issues as a list.

As an example, the following outlines a component that uses the above API endpoint for validating an email from the user.

<script>
  import { PUBLIC_API_URL } from "$env/static/public";
  let errors = $state([]);

  const submitForm = async (e) => {
    e.preventDefault();
    errors = [];

    const form = Object.fromEntries(new FormData(e.target));
    const response = await fetch(`${PUBLIC_API_URL}/api/emails`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(form),
    });

    const data = await response.json();
    if (data.success === false) {
      errors = data.error.issues;
      return;
    }

    // logic for successful response
  };
</script>

<form onsubmit={submitForm}>
  <label>
    Type in your email
    <input type="email" name="email" id="email" />
  </label>
  <br/>
  <input type="submit" value="Send email to server" />
</form>

{#each errors as error}
  <p>Error: {error.message}</p>
{/each}

The above approach displays all errors at once. For better UX, you could also parse the path array to show errors next to specific input fields:

// Group errors by field
const errorsByField = {};
data.error.issues.forEach(issue => {
  const fieldName = issue.path[0];
  if (!errorsByField[fieldName]) {
    errorsByField[fieldName] = [];
  }
  errorsByField[fieldName].push(issue.message);
});

// Now display errors next to each field, e.g.
{#if errorsByField.email}
  <p>Error: {errorsByField.email.join(', ')}</p>
{/if}
<label>
  Type in your email
  <input type="email" name="email" id="email" />
</label>
Loading Exercise...

Summary

In summary:

  • Input validation ensures data meets requirements before processing, preventing security vulnerabilities, data corruption, and unexpected behavior.
  • Client-side validation improves user experience but can be easily bypassed.
  • Server-side validation is essential for security because it’s the only environment you control. All data must be validated on the server regardless of client-side checks.
  • Database constraints provide a final validation layer that enforces data integrity even if application code has bugs.
  • Zod provides schema-based validation that reduces repetitive code, ensures consistency, and provides structured error handling.
  • The defense-in-depth approach - validating at multiple layers - provides the best protection against invalid or malicious data.