Server-side verification means your server checks every piece of data it receives before it uses it, whatever the browser already checked. A user can't switch it off, which makes it the check your app's security depends on.
Why the browser's checks aren't enough
Client-side checks run in the user's browser: a required or maxlength attribute on an input, or a bit of JavaScript that turns a field red. They give quick feedback, so keep them. But the browser belongs to the user, and the user can change anything that runs in it.
Someone can delete the maxlength attribute in DevTools, turn JavaScript off, or skip the page altogether and send a request straight to your API:
curl -X POST https://example.com/signup \
-H "Content-Type: application/json" \
-d '{"name":"","email":"nope","password":"1"}'That request never touched your form, so none of its checks ran. The server is the first and only code that sees it. The same goes for your mobile app, a script someone writes against your API, or a bot: none of them have to use your front end.
So treat client-side checks as a convenience for honest users, and server-side checks as the rule for everyone.
What the server should check
Think of it as four layers, from the cheapest to the most specific.
Type and syntax
Is the body valid JSON at all? Is email a string, and quantity a number, not an array or an object? A lot of strange bugs start with a field that turned up as a different type from the one the code expected.
Format
Does the value have the right shape? An email has one @ and a domain. A date parses as a date. A postcode or a username matches its pattern.
A format check can't tell you whether an email address is real, only that it looks like one. To know someone owns the address, send a confirmation link and wait for them to click it.
Limits
Is it the right size? A name between 1 and 50 characters, a password of at least 12, a tuna order under 1,000 tins. Limits stop mistakes, and they also stop someone sending a 10 MB 'name' to see what breaks. Set a maximum on everything, including how large the request body can be.
Business logic
Does the request make sense for your app, right now? Is the delivery date in the future? Is there enough tuna in stock? Does the coupon exist and has it expired? These checks need your data, which is another reason they can only live on the server.
A close cousin is authorisation: is this user allowed to change this order at all? That is a separate question from 'is the data well formed', covered in Authentication vs Authorization, but it belongs in the same place, on the server, on every request.
A worked example: a sign-up endpoint
Here is a small validator for a sign-up form with a name, an email and a password, in plain JavaScript:
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validateSignUp(body) {
const errors = {};
const name = typeof body?.name === "string"
? body.name.trim() : "";
const email = typeof body?.email === "string"
? body.email.trim().toLowerCase() : "";
const password = typeof body?.password === "string"
? body.password : "";
if (name.length < 1 || name.length > 50) {
errors.name = "Name must be 1-50 characters.";
}
if (email.length > 254 || !EMAIL.test(email)) {
errors.email = "Enter a valid email address.";
}
if (password.length < 12 || password.length > 128) {
errors.password = "Use 12 to 128 characters.";
}
// Only the fields we checked, nothing else
return { errors, value: { name, email, password } };
}And the route that uses it, with Express:
app.use(express.json());
app.post("/signup", async (req, res) => {
const { errors, value } = validateSignUp(req.body);
if (Object.keys(errors).length > 0) {
return res.status(400).json({ errors });
}
await createUser(value); // never req.body
res.status(201).end();
});Four details in this code matter:
- Types come first. Anything that isn't a string becomes an empty string, which then fails the length check, so a field that arrives as an object can't slip through.
- The response lists every field that failed, so the form can show all the errors together rather than one at a time.
- The route saves
value, notreq.body. If someone adds"isAdmin": trueto the request, it never reaches the database. - A 400 Bad Request status says the client sent something wrong. Some APIs use 422 for 'well-formed but invalid'; either is fine as long as you are consistent.
In production code you would describe the same rules with a schema library, such as Zod in JavaScript or Pydantic in Python, rather than writing every if by hand. The library checks types, formats and limits from one definition and gives you the errors in a consistent shape.
Validate, sanitise or escape?
The video mentions sanitising data, and the three words get mixed up, so it helps to keep them apart.
- Validating checks a value and rejects it if it's wrong. This is the default for nearly every field.
- Sanitising changes a value to make it acceptable: trimming spaces, lower-casing an email, or stripping unsafe tags from rich text with a library built for it. Keep it to small, predictable fixes. Quietly rewriting what someone typed usually causes more confusion than an error message would.
- Escaping happens when you use the data, not when you receive it. Put it in HTML and it gets HTML-encoded; put it in SQL and it goes in as a parameter, never pasted into the query string.
Validation can't replace escaping. A perfectly valid name such as O'Brien still contains a quote, and it is the parameterised query that keeps it from breaking your SQL.
Prefer allow-lists over deny-lists. Saying what is allowed ('letters, digits and dashes, up to 30') is far safer than trying to list everything dangerous ('no <script>'), because attackers are better at finding the gaps in a deny-list than you are at filling them.
Keep the client-side checks too
None of this makes front-end checks pointless. They catch typos before a round trip and make the form feel quick. Keep the two from drifting apart: if your front end and back end are both JavaScript or TypeScript, share the same schema between them, so a rule changed in one place changes in both.
A database constraint is a useful last line behind both. A UNIQUE index on the email column catches the two sign-ups that arrive in the same millisecond, which a 'does this email exist?' check in code can miss.
Common mistakes
- Trusting values the server should own: prices, user ids, roles and discounts come from your database, never from the request, even from a hidden form field.
- Checking the form but not the API: every way in needs the same checks, including JSON bodies, query strings, headers, file uploads and webhooks.
- Saving the whole request body: pick out the fields you validated; anything else is a way in for fields you never meant to expose. This is often called mass assignment.
- Checking one value and storing another: if you validate the trimmed email, store the trimmed email.
- Saying too much in errors: 'Email must be valid' is helpful. A stack trace, or a login error that says which of the email or password was wrong, helps an attacker more than a user.
For a wider look at all the places data can get into a system, see Attack Surfaces.
Key takeaways
- Client-side checks are for convenience; anyone can bypass them, so the server must check everything again.
- Check type, format, limits and business rules, and set a maximum on every field.
- Validate on the way in, escape on the way out, and prefer allow-lists to deny-lists.
- Save only the fields you validated, never the raw request body.
- Answer bad input with a clear 400 or 422 and field-level errors, without leaking internals.