A real incident, reproduced in three lines.
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // looks harmless
});The endpoint returns a user. It works. Then someone opens the browser console and sees:
{
"id": 42,
"name": "Priya",
"email": "priya@example.com",
"passwordHash": "$2b$10$N9qo8uLOickgx2ZMRZoMy...",
"resetToken": "a7f3c9e1-...",
"isAdmin": false,
"internalRiskScore": 0.23
}You have published password hashes, a live password-reset token, and an internal scoring field nobody outside the company should know exists. Nobody wrote code to expose these. They arrived automatically, because the database row was handed straight to the response.
Add a column next sprint and it appears in the API too. Your API surface is now whatever your schema happens to contain — and it changes without anyone deciding.
The DTO: deciding what leaves
A Data Transfer Object is a deliberately shaped object for moving data across a boundary. It is a small, boring class whose entire value is that somebody chose its fields.
class UserResponse {
constructor(user) {
this.id = user.id;
this.name = user.name;
this.email = user.email;
this.memberSince = user.createdAt.getFullYear();
}
}
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(new UserResponse(user));
});Now adding internalRiskScore to the table changes nothing externally. Exposure became an explicit act rather than a default.
Why this also protects you from your own schema
The second benefit is subtler and arrives later.
Rename the column name to full_name. With entities returned directly, every API client breaks. With a DTO, you change one line inside UserResponse and the contract stays stable.
The DTO decouples your storage from your public interface, so you can refactor the database without negotiating with every consumer.
Inbound DTOs and the mass-assignment hole
The same reasoning applies in reverse, where the risk is worse.
app.post('/api/users', async (req, res) => {
const user = await User.create(req.body); // dangerous
res.json(user);
});The client controls req.body entirely. So a user can send:
{ "name": "Attacker", "email": "a@b.com", "isAdmin": true, "credits": 999999 }and hand themselves administrator rights. This is mass assignment, and it is a genuinely common vulnerability. The code never intended to accept isAdmin — it just never said which fields it would accept.
An inbound DTO states that explicitly:
function toCreateUserCommand(body) {
return {
name: body.name,
email: body.email,
password: body.password,
}; // anything else the client sent is discarded
}Whitelist the fields you want. Never spread untrusted input into a database call.
Validation: rejecting bad data at the door
DTOs decide which fields cross the boundary. Validation decides whether their values are acceptable.
Validate at the boundary, before the data reaches business logic:
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(1).max(100).trim(),
email: z.string().email().toLowerCase(),
password: z.string().min(12),
age: z.number().int().min(13).max(120).optional(),
});
app.post('/api/users', async (req, res, next) => {
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.issues });
}
const user = await userService.create(parsed.data); // guaranteed valid shape
res.status(201).json(new UserResponse(user));
});Everything past that check can assume the data is well-formed. That assumption is worth an enormous amount: it removes defensive if (!user.email) checks scattered through every layer downstream.
Diagram, as text
- Raw request untrusted leads to Validate
- Validate leads to 400 with field errors (invalid)
- Validate leads to Service trusts the shape (valid)
What good validation covers
Beginners check presence and stop. Each of these is a real bug class:
| Check | Missing it causes |
|---|---|
| Presence | Null reference crashes deep in the stack |
| Type | "5" + 5 becoming "55"; string sent where number expected |
| Range | Negative order quantities, ages of 40,000 |
| Length | A 2GB "name" filling your database or memory |
| Format | Unusable email addresses, malformed dates |
| Business rules | An end date before the start date |
The last row deserves care. "Is this a valid email format" belongs in the schema. "Does this email already exist" belongs in the service, because it requires a database lookup and is a domain rule, not a shape rule.
Error messages that help
Returning 400 Bad Request with no detail forces the client to guess. Return which field failed and why:
{
"errors": [
{ "field": "email", "message": "must be a valid email address" },
{ "field": "password", "message": "must be at least 12 characters" }
]
}Report all failures at once, not just the first. Otherwise the user fixes one problem, resubmits, and discovers another — repeatedly. One thing to withhold: never reflect why a login failed in detail, since "no such email" versus "wrong password" tells an attacker which accounts exist.
Check your understanding
Common mistakes
Returning entities "just for now". It is never just for now. The field that leaks is always one nobody remembered was on the model.
Validating in the service instead of the boundary. By then the invalid data has already travelled through your code, and each layer needs its own defensive checks.
Trusting internal callers. A DTO from another one of your services can still be malformed after a deploy. Validate at every trust boundary, not only the public one.
Sanitising instead of rejecting. Silently stripping characters from input produces surprising results and hides attacks. Reject clearly and say why.
One DTO for everything. The shape you accept when creating a user, the shape you accept when updating, and the shape you return are three different things. Sharing one class forces every field to be optional, which defeats validation.
Practice
Design the validation for a 'create user' endpoint accepting email, password, display name and date of birth. State what is checked where, and which checks cannot live at the boundary.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Separate checks that need only the request from checks that need the database or the domain.
Show a strong answer
At the boundary, on the inbound DTO — checks answerable from the request alone:
- Presence and type. All four fields present, all strings except the date.
- Email is syntactically an email, with a length cap. Do not attempt an exhaustive regex; the real test is whether a confirmation message arrives.
- Password meets a length minimum — length is the property that matters most, and complexity rules mostly push people towards predictable substitutions. Cap the maximum too, so a very long input cannot be used to make hashing expensive.
- Display name length and character rules, including rejecting control characters and zero-width characters used for spoofing.
- Date of birth is a real date, in the past, and yields a plausible age.
These reject early, cheaply, and return field-level errors so the client can highlight the offending input.
In the service layer — checks needing the domain or the database:
- Email is not already registered. Requires a query, and is a business rule about uniqueness rather than a shape rule.
- The age meets the product's minimum, which is policy and may vary by jurisdiction.
- The password is not in a known-breached list, which needs an external check.
What cannot live at the boundary at all: the uniqueness check is not merely "not at the boundary" — it cannot be made reliable by checking. Two simultaneous registrations both pass the check and both insert. The only correct enforcement is a unique constraint in the database; the service-layer check exists to produce a friendly message, not to guarantee uniqueness.
That is the general shape: validation gives good errors, constraints give guarantees, and confusing the two produces a race.
Your API returns a 400 with the body `{ "error": "Validation failed" }` for any bad input. Rewrite the contract, and say what the client can now do that it could not before.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Think about a form with eight fields and one wrong value.
Show a strong answer
The problem concretely: a user fills in eight fields, one is wrong, and the interface can only show a banner saying something is wrong. The user must guess. If two fields are wrong they discover them one at a time.
The rewritten contract — status 422, since the body parsed and the values failed rules:
- A stable machine-readable code per failure, such as
too_shortoralready_registered— clients branch on this and prose changes break them. - The field each failure refers to, as a path so nested objects work.
- A human-readable message safe to display.
- All failures at once, not the first.
- A correlation ID at the top level, so a support conversation can be tied to a log entry.
What the client can now do:
- Highlight the right input and put the message next to it, rather than a banner at the top.
- Show every problem in one pass, so the user fixes them together instead of resubmitting four times.
- Localise. The client can translate from the code, which it cannot do from an English sentence.
- Distinguish 'fix your input' from 'try again later', because the codes carry meaning the status alone does not.
- Handle specific cases specially —
already_registeredcan offer a sign-in link instead of an error.
One thing to be careful about: already_registered tells an attacker which email addresses have accounts. That is a genuine trade-off between usability and enumeration, and it should be made deliberately — usually by keeping the friendly message on registration and being vague on password reset, where the risk is higher.
Key takeaways
- Returning database entities directly makes your API surface whatever your schema contains — and it changes silently when you add a column.
- A DTO is an explicit decision about what crosses a boundary: entities model what you store, DTOs model what you promise.
- DTOs also decouple storage from contract, so you can rename columns without breaking clients.
- Spreading untrusted input into a model creates mass assignment. Whitelist fields; never pass
req.bodystraight through. - Validate at the boundary so every layer downstream can trust the shape, and prefer parsing into a typed value over a boolean check.
- Client-side validation is a courtesy, not a control. Always validate server-side, and return all field errors at once.
Further reading
- OWASP, Mass Assignment Cheat Sheet — the allow-list argument.
- RFC 9457, Problem Details for HTTP APIs — a standard error shape with per-field detail.
- NIST Special Publication 800-63B, Digital Identity Guidelines — evidence-based password rules, including why complexity requirements underperform length.
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — the Data Transfer Object.
- Alistair Cockburn, "Hexagonal Architecture" (2005) — translating at the boundary rather than passing internal types outward.