TLDR: Your frontend can be bypassed in 30 seconds with curl. Validate everything server-side — IDs, tokens, route params — as if the client doesn't exist.

The Setup

We were building a chat tool for a cancer education business — a chat tool that helps cancer patients find and connect with treatment centers.

The flow sounds simple: chat with an AI, get matched to facilities, click "Contact All N Centers," and your lead info fires off to every matched center at once.

The bulk consultation endpoint at /api/consultations/bulk takes a list of facility IDs from the client and creates a consultation record for each one.

Clean. Efficient. And completely open.

The Wall

Here's what the original code was actually doing: the client sent a list of facility IDs and the server just… used them.

No check that those IDs were real.

No check that they matched the facilities actually surfaced in that chat session.

Anyone with devtools — or a curl command — could POST arbitrary IDs and create consultation records for any facility in the database.

That's not a theoretical risk when you're building an intake form for cancer patients. That's a genuine problem.

And it wasn't just the bulk endpoint. Conversation records had an IDOR too (Insecure Direct Object Reference — accessing records you don't own by guessing their ID). Know a conversation ID? Read it. No ownership check. No binding at all.

What I Got Wrong First

My instinct was to lock the sessionToken down hard — strict UUID format, no exceptions.

That broke real users.

Turns out some valid sessions had tokens that weren't perfect UUIDs. The strict check rejected legitimate traffic and I had to relax it back to a plain string.

The lesson is subtle: I confused format strictness with security. A UUID-shaped token isn't safer than any other token. What matters is whether the server is actually using it to verify ownership. The relaxed string check still bound every conversation to its token on the server side. That's the security. The format check was just noise that broke things.

The Fix That Actually Worked

Four things shipped together:

  • Facility ID validation in the bulk endpoint — the server now cross-checks every submitted ID against the database before creating any records. Bad IDs get a clean 400.
  • Session token binding on conversations — tied to the token at creation; every subsequent request must present the matching token or it's rejected outright.
  • Zod schemas on every API route — Zod is a TypeScript schema validator. Instead of scattered typeof guards, every route now has a declared shape. Anything that doesn't match fails loudly at the boundary.
  • parseRouteId() in src/lib/route-utils.ts — replaces every raw Number(id) and parseInt call across admin [id] routes and facility [id] routes. Raw parseInt("abc") silently returns NaN and produces cryptic 500s. parseRouteId() rejects NaN, zero, and negatives immediately. One utility, applied everywhere in one pass.

Why This Matters to Me

The frontend felt secure because the UI only lets you do the right thing.

But the UI is not your security layer.

It's a polite suggestion.

Anyone can open the network tab, copy a request as curl, and replay it with whatever payload they feel like. The only thing standing between your database and the world is what your server actually validates.

Client-side validation is UX. Server-side validation is security.

They are not the same thing — and mixing them up is how you end up with a cancer patient intake form that silently accepts injected data.

Treat every value that arrives from the client as untrusted. IDs, tokens, route params, all of it. Validate server-side as if the client doesn't exist.