TLDR: Persisting a conversationId in localStorage is easy. Securing it — so one patient can't accidentally read another's — takes one more step that's easy to skip.

The App

A cancer education business needed a chat-based facility finder — a Next.js app I've been building in the open with an AI dev council.

The core flow: a scared patient lands on the chat, describes their situation, the AI surfaces matching cancer treatment centers, and midway through the conversation, the patient fills out a lead form so a facility can follow up.

It was working.

Except for one brutal UX failure.

The Wall

Close the tab and come back later — everything was gone.

The conversation, the facility cards, the half-typed message, all of it. For a patient who started a search one afternoon, got interrupted, and came back the next morning… they had to start from scratch.

That's not a minor inconvenience. These are people navigating a terrifying situation. Making them repeat themselves felt genuinely cruel.

What I Did First — and What Bit Me

The fix looked obvious: save the conversationId to localStorage on the client, send it back on the next visit, and the server resumes right where it left off.

I shipped it.

Then I looked at the API handler and felt that particular chill.

The server was loading the full conversation — including every message — based on a client-supplied ID. No proof that this client actually owned that conversation. Just… "here's a UUID, give me the chat."

That's an IDOR (Insecure Direct Object Reference, basically "I can read your data by guessing or stealing your ID").

On most apps, a leaked conversation is embarrassing. On this app, the conversations contain symptoms, diagnoses, treatment histories, and contact info. The stakes were not abstract.

The Fix That Actually Worked

The solution was to generate a sessionToken — a separate secret, also stored in localStorage — and bind it server-side to the conversationId on first creation.

Every subsequent request has to present both. If the token doesn't match the conversation, the server treats it as a new session.

The fiddly part: null vs undefined in the request body. Took two commits to get right — first relaxing the UUID validation to accept any string, then coercing null to undefined before the payload was sent. Small things, but they blocked the whole feature until resolved.

Once that was solid, I layered on the real win: storing clientLeadInfo (name, email, phone) in localStorage too.

On a return visit, the chat handler gets that info in the request body, seeds the lead immediately via findOrCreateLead, and skips the OpenAI extractor entirely — no extra roundtrip, no re-prompting the patient for details they already gave. The lead form pre-fills for a one-click confirm.

The facility cards restore. The draft message restores. The session just… continues.

Why This Matters

The lesson isn't "use localStorage for sessions." Lots of people do that.

The lesson is: the moment localStorage carries identity, it's an attack surface.

A pointer is fine. A secret that binds that pointer to a specific owner is mandatory. Skip it on a todo app and no one gets hurt. Skip it on a health app and you've got one patient reading another patient's cancer conversation.

Persist the pointer. Bind it to a token. Never let the client assert who they are — only let them prove it.