TLDR: Storing a resource ID in the browser is not ownership. Bind every object to a secret token and verify it server-side — or anyone who guesses the ID can read the data.

The Feature That Opened the Door

We shipped conversation persistence into an AI chat tool we built for a cancer education business — a chat assistant for cancer patients searching for oncologists and treatment facilities.

Smart UX, actually. Store the conversationId in localStorage so returning users could pick up exactly where they left off.

What we didn't build was ownership.

The Hole

Each conversation lived in the database with a numeric ID.

When a user came back, the client sent that ID, and the server returned the data.

No check. No gate. No proof that the person asking was the person who started the conversation.

That's an IDOR. Insecure Direct Object Reference — the class of vulnerability where you hand out IDs to resources without ever verifying the person holding the ID is allowed to access them.

"So what?" — GREAT question.

For a to-do app? Embarrassing.

For a chat where a cancer patient just described their diagnosis, their fear, and their location while searching for a doctor… that's a REAL data breach.

The Security Auditor in our autonomous AI agent loop (a set of AI agents we run in cycles to find bugs, polish UX, and harden security) caught it.

The Fix That Worked

Conceptually simple once you see it.

On conversation creation, generate a random UUID session_token and store it in the DB alongside the conversation.

The API returns that token on the very first message. The client stores it in localStorage and sends it back with every subsequent request.

Both the /api/chat endpoint and /api/conversations/[id]/messages endpoint now verify the token matches. Wrong token? 403. Done.

One drizzle-kit push (our DB migration tool, part of the Drizzle ORM ecosystem) to add the session_token column, and the hole was closed.

Then I Clamped Too Hard

Here's where I have to be honest.

My Zod (a TypeScript validation library) schema validated the incoming token as z.string().uuid().

Seemed totally reasonable. We were generating UUIDs.

Except… returning users who'd visited before the fix had old tokens sitting in their localStorage. Tokens that weren't UUIDs. They started hitting 400 errors and couldn't resume their conversations.

I locked the door and locked out the people who were supposed to walk through it.

The fix: drop .uuid(), use .max(255) instead.

The format of the token doesn't matter. The DB match IS the security check. Enforce format at write time — not read time.

Then one more edge case bit me: localStorage returning null (not undefined) for a missing token. The API expected undefined. Sending literal null was failing validation. One coercion before the request goes out, and that was it.

Why This Matters to Me

An ID is not a password.

Holding a reference to something is not the same as owning it.

Every endpoint that returns data based on an ID needs to answer: "Did this specific session create this resource?"

If you can't answer yes… you have an IDOR. And depending on what lives in that data, the stakes vary wildly.

Ours happened to hold the private health conversations of cancer patients. Not the place to find out the hard way.

Build the ownership check BEFORE the feature ships. Not in the next commit.

Before.