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
# 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.
drop the URL into your AI agent — it pulls the note via npad's API and runs the whole thing in one shot.