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.
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:
- A database dump does not reveal working API keys. An attacker who exfiltrated D1 rows still can't authenticate as any user.
- 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.
Magic-link URLs are never returned in HTTP responses
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.
Session cookies
The magic-link flow issues an HMAC-signed session cookie on successful /auth/verify. The cookie is:
- HttpOnly — JavaScript can't read it, so XSS on the dashboard can't steal it.
- Secure — sent only over HTTPS.
- SameSite=Lax — sent on same-site navigations but not cross-site POSTs, defeating a class of CSRF attacks.
- Max-Age 30 days — bounded lifetime, no refresh dance.
- Signed with
MAGIC_LINK_SECRET— an HMAC of{user_id, expires_at}. A tampered cookie fails verification and is rejected.
What Cloudflare handles on our behalf
Some concerns just aren't ours to worry about because the whole stack sits on Cloudflare:
- DDoS. Cloudflare's edge absorbs volumetric attacks before requests hit the Worker.
- TLS. Cloudflare terminates HTTPS with modern ciphers, HSTS on gnosem.dev + gnosem.com.
- Encryption at rest. D1 and Vectorize encrypt storage transparently.
- Physical + network security. Cloudflare's compliance program covers SOC 2, ISO 27001, PCI DSS.
What we haven't done yet (honesty)
- Rate limiting on
/signupand/auth/request. No per-IP quota today. An attacker could spam signups or magic-link requests. The impact of the latter is bounded (Brevo will 429 us before serious cost) but real. Adding D1-backed sliding-window limits is the next security workstream. - End-to-end encryption. Memories are stored server-side in plaintext (Cloudflare encrypts at rest, but we hold the key). If you require zero-knowledge storage, this isn't the product yet. Local-first sync with user-held keys is on the roadmap.
- Formal security audit. No third-party pentest yet. If you're in a regulated industry and want one, tell us — we'll get one before you'd need to sign a contract.
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.