GNOSEM
Launch

Gnosem's security posture — what we do to keep your memory yours

Two-layer per-user isolation, no plaintext key storage, magic links that never leak, and the specific bug we shipped and patched to earn that guarantee.

By CUETV LLC · · 6-minute read

Every hosted memory service is a multi-tenant vector database with a public URL. If tenant A can read tenant B's memories, the product is broken and probably illegal. This post walks through the specific defenses Gnosem uses to make cross-tenant reads impossible — and one case study of what happens when a "small convenience" flag punches a hole in that guarantee.

Two-layer per-user isolation

Isolation is enforced at two independent layers. A bug in one layer alone doesn't create a leak.

Layer 1 — application. Every Bearer token maps to exactly one user_id in the D1 api_keys table. Every SQL query that touches memory data includes WHERE user_id = ? bound to that authenticated user. There is no code path in the codebase — search / list / forget / supersede / export — that fetches memory rows without that filter. A broken tool implementation that tried to fetch memories by id alone would still fail because the query wrappers all require user_id.

Layer 2 — vector engine. Every Cloudflare Vectorize query passes filter: { user_id: <user_id> }. Vectorize applies this filter inside the vector search itself — vectors from other users are excluded from the candidate pool before scoring. This is not just belt-and-suspenders; it means a bug in the D1 layer alone can't leak vectors across tenants. The vector store simply won't return them.

For the metadata filter to actually work, the index needs a metadata index on user_id at provisioning time:

npx wrangler vectorize create-metadata-index <index> \
  --property-name=user_id --type=string

Without that command, filter queries silently return empty results — a subtle footgun. We hit it in early testing and it cost half an hour of "why is search returning nothing?" debugging. The fix was one command; the failure mode is well-hidden.

API keys are stored hashed, never in plaintext

When a user calls POST /signup, the server generates a 128-bit random key formatted as gn_<32 hex>, returns the plaintext once, and stores only the SHA-256 hash in D1. On every subsequent request, the incoming Bearer token is hashed and compared against stored hashes. The plaintext never lives in our database.

This has two useful properties:

  1. A database dump does not reveal working API keys. An attacker who exfiltrated D1 rows still can't authenticate as any user.
  2. The dashboard can't "reveal" your existing key on demand, because we don't have it. If you lose your key, you rotate — which revokes the old and issues a new one. Slightly less convenient than a "show my key" button, materially more secure.

This one is the case study. In an early version of the magic-link auth code, the POST /auth/request endpoint had a "fallback for testing" branch: if the email provider wasn't configured, it would return the sign-in link in the JSON response body instead of emailing it. The idea was to let developers test the flow before wiring up email delivery.

That's an account-takeover vulnerability. Because /auth/verify trades the link for a 30-day session cookie, any unauthenticated caller who knew a registered user's email address could take over that account without inbox access. The fallback flag punched a hole through the "you need to receive the email" security boundary.

We caught it and patched it in ab12466. The new behavior: the endpoint returns an identical opaque success message whether or not email was actually sent, whether or not the email is registered, whether or not the provider is configured. Server-side send failures are logged; nothing is echoed back.

The same commit fixed a related bug: the endpoint had been returning different response bodies for registered vs. unregistered emails, making it an account-enumeration oracle even though the comment claimed otherwise. Both branches now return the same body, so an attacker can't distinguish "this email has an account" from "this email doesn't."

Lesson: "convenience for testing" flags in security-sensitive endpoints are the class of bug that gets exploited the day after launch. If a test path needs to bypass a security boundary, gate it behind a compile-time or config-time flag that is impossible to trip in production — not a runtime fallback.

The magic-link flow issues an HMAC-signed session cookie on successful /auth/verify. The cookie is:

What Cloudflare handles on our behalf

Some concerns just aren't ours to worry about because the whole stack sits on Cloudflare:

What we haven't done yet (honesty)

Every gnosem write ships with provenance (written_by and session_id), so if a client ever writes something unexpected, you can trace which client + session did it. Combined with auditing via memory_list, that gives you a full record of who touched your memory.

The source is MIT-licensed on GitHub. If you want to audit any of this yourself, that's the fastest path — src/worker.js is one file and the security-critical paths are marked with comments.