● public · updated 17 days ago · id z8mqn6478w ·7,205 chars

Anonymous AI chat with persistent memory: the signed-cookie pattern (no login required)

● Anonymous AI chat with persistent memory: the signed-cookie pattern (no login required).md 7,205 chars · read-only
# Anonymous AI chat with persistent memory: the signed-cookie pattern

Most AI chat products force you to sign up before they remember anything. ChatGPT, Claude, Perplexity — all require an account before they save your conversation. But "type and go" with persistent memory is a real product moment: the visitor wants to be useful to themselves without filling out a form first.

Here's the pattern for giving anonymous users a **durable, tamper-proof identity** that survives refreshes and devices for the lifetime of a browser, without any login flow. It's what we shipped on a recent AI chat build, and it ended up being more interesting than I expected.

## The shape of the problem

You need a per-visitor identifier that:

1. **Persists** across page reloads, days, weeks (otherwise no memory)
2. **Isolates** each user (your chat ≠ someone else's chat on the same product)
3. **Resists forgery** (attacker can't pretend to be another user)
4. **Resists theft via JavaScript** (XSS or malicious browser extension shouldn't be able to dump everyone's session ids)
5. **Requires no account** (you didn't ask the user to log in)

A bare `localStorage.setItem('uid', uuid)` fails 3 and 4. A signed JWT-in-localStorage fails 4. The right primitive is the boring old **HttpOnly cookie, signed with an HMAC server-side**.

## The pattern

### 1. Server mints + signs on first visit

```ts
// Pseudocode for a Next.js API proxy (or any backend)
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.SESSION_SECRET!;  // 32+ random bytes, env-injected

function sign(uid: string): string {
  const sig = createHmac('sha256', SECRET).update(uid).digest('hex').slice(0, 32);
  return `${uid}.${sig}`;
}

function verify(value: string | undefined): string | null {
  if (!value) return null;
  const dot = value.lastIndexOf('.');
  if (dot === -1) return null;
  const uid = value.slice(0, dot);
  const sig = value.slice(dot + 1);
  const expected = createHmac('sha256', SECRET).update(uid).digest('hex').slice(0, 32);
  try {
    const a = Buffer.from(sig, 'hex');
    const b = Buffer.from(expected, 'hex');
    if (a.length !== b.length) return null;
    return timingSafeEqual(a, b) ? uid : null;  // constant-time compare
  } catch { return null; }
}

function mintUid(): string {
  return `web_${randomUUID()}`;  // 122 bits of randomness, unguessable
}
```

### 2. Set as HttpOnly + Secure + SameSite=Lax

```ts
function buildSetCookie(signedValue: string): string {
  return [
    `session_uid=${signedValue}`,
    'Path=/',
    'Max-Age=31536000',     // 1 year
    'HttpOnly',              // ← JS cannot read it, even from the same origin
    'SameSite=Lax',          // ← blocks most CSRF
    'Secure',                // ← HTTPS only (production)
  ].join('; ');
}
```

The cookie flows back automatically on every request. The client-side JavaScript never sees its value.

### 3. Verify on every request, derive the trusted uid

```ts
async function handler(req: Request) {
  const cookieValue = readCookie(req, 'session_uid');
  let uid = verify(cookieValue);

  if (!uid) {
    // First visit, or stolen/forged cookie — mint a fresh one
    uid = mintUid();
    const setCookieHeader = buildSetCookie(sign(uid));
    // attach to response
  }

  // From here on, `uid` is server-trusted. Use it as the DB key for chat history.
}
```

### 4. If forwarding to another backend, do it server-to-server

Don't let the client send the uid in headers or query params — they could spoof it. Instead, the proxy attaches the verified uid as a header:

```ts
const upstream = await fetch(`${BACKEND}/chat`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Verified-Uid': uid,        // ← set by the proxy, AFTER verifying the cookie
  },
  body: req.body,
});
```

Crucially, the **backend service must only accept this header from trusted upstreams** (your proxy). In a Kubernetes setup that's enforced by ingress — the backend service isn't directly exposed to the public internet.

## What this gets you

- **Same user across refreshes, days, weeks**: cookie has 1-year max-age
- **Different users isolated**: each gets their own UUID v4
- **Forgery-proof**: without `SECRET`, attacker can't craft a valid signature
- **XSS-proof identity theft**: HttpOnly means `document.cookie` doesn't expose it
- **Zero signup friction**: page loads → cookie minted → user types → conversation persists

## The threat model (be honest about it)

This is **session-grade security, not account-grade**. Specifically:

- **Cookie theft = identity takeover.** If someone gets the raw cookie value (compromised device, server access, etc.), they can act as that user. Same risk surface as any cookie-based login.
- **Same browser = same user.** Two people sharing a laptop share the chat history. Same as ChatGPT pre-login.
- **No cross-device.** Different browser = different cookie = different memory. (If you need cross-device memory, you need an account.)
- **Cookie clears = memory gone.** User clears cookies, goes incognito, or deletes the cookie via devtools → fresh identity, prior chat orphaned.

These are acceptable trade-offs for a "try it before you sign up" experience. They're not acceptable for storing high-stakes PII or payment info — for that, escalate to real auth.

## When to upgrade

Build the anonymous flow as a layer that can later coexist with real auth:

- When the user logs in, **migrate the anonymous uid's data to their real user_id** (or just present them with both histories).
- The signed-cookie identity is the "free tier"; the authenticated identity unlocks cross-device sync, persistent memory across "New chat", server-side memory of facts, etc.

## Common mistakes I've seen

1. **Storing the uid in localStorage instead of an HttpOnly cookie.** Any XSS or browser extension dumps every user's session.
2. **Signing with a public secret** (or no secret at all). Anyone reading the codebase can forge cookies.
3. **Trusting a uid that comes from the client in a header.** Always read the cookie server-side; never let the client tell you who they are.
4. **`process.env.NODE_ENV` as the "is this prod" check** for enforcing the secret requirement. In Next.js standalone, NODE_ENV is always `"production"` at runtime regardless of environment — use a custom env var.
5. **Comparing the HMAC with `===`.** Use `timingSafeEqual` or you leak signature information through timing.
6. **No rate limit on the cookie endpoint.** An attacker can spam mint requests; rate-limit by IP as a backstop before the cookie even exists.

## TL;DR

If you want anonymous AI chat with persistent memory:

- **UUID v4 as the identity** (122-bit random, unguessable)
- **HMAC-signed**, stored in an **HttpOnly + Secure + SameSite=Lax** cookie
- **Verified server-side on every request** with `timingSafeEqual`
- **Never let the client send the uid in headers** — derive it from the cookie
- **Treat it as session-grade security**, escalate to real auth for higher-stakes operations

That's the entire pattern. No JWT library, no OAuth, no signup. ~50 lines of crypto and one k8s secret. The user types and it remembers them.
feed this URL to your AI agent and watch it one-shot the task
https://npad.run/p/anonymous-ai-chat-with-persistent-memory-httponly-signed-coo-z8mqn6478w
install npad

drop the URL into your AI agent — it pulls the note via npad's API and runs the whole thing in one shot.