Security
Per-user encryption, database-enforced isolation, and a relentless focus on getting the boring details right.
JauMemory is built on a defense-in-depth model. Every layer assumes the layer above it might be compromised. Application bugs are caught by the database; database compromises are caught by per-user encryption; key disclosure is contained by per-user key derivation. Below is what that actually looks like.
How your data is encrypted, and what that means
Your memories are encrypted at rest with AES-256-GCM using a key unique to your account, travel only over TLS, and are isolated in the database by row-level security that even our own application role cannot bypass.
This is server-side encryption, not end-to-end encryption. Our servers can decrypt your content at processing time, and that is a deliberate trade-off, not an accident. It is what makes JauMemory's core features possible: semantic search over your memories, automatic classification, consolidating duplicates into insights, and recalling your context from any device or AI tool. End-to-end encryption would blind every one of those features, because a server that cannot read your data cannot search, classify, or consolidate it either.
We contain the risk of that trade-off in layers:
- Per-user keys: one account's ciphertext cannot be decrypted with another account's key context.
- FORCE row-level security: the database itself refuses to return another user's rows, even to the table owner.
- Device-bound, revocable sessions and TOTP two-factor authentication on your account.
- No third-party AI APIs: search and classification run on our own servers; your content never leaves them for processing.
- GDPR-style export and deletion: take your data out or remove it entirely, at any time.
The same model applies to the credential vault, with one extra restriction: vault secrets are write-only. You store an API key once, encrypted; agents can use it to act on your behalf, but no tool ever reads the plaintext back out, and it never appears in a chat transcript.
If your threat model requires that no server operator can ever technically access your plaintext, an E2E-encrypted product is the right choice and JauMemory is not it. If you want strong at-rest encryption plus a memory that can actually think, this is the design that delivers it.
Encryption
Memory content is encrypted with AES-256-GCM using a key unique to each user. The per-user key is derived from a server-side master key (ENCRYPTION_MASTER_KEY) and the user's ID, so a stolen ciphertext for one account cannot be decrypted with another account's context.
Encryption at rest
Every memory body, TOTP secret, recovery artifact, and credential vault entry is sealed before it touches PostgreSQL. An operator with read-only database access sees ciphertext, nonces, and auth tags, never plaintext.
// Per-user key derivation (conceptual)
let user_key = derive(MASTER_KEY, user_id);
let (ciphertext, nonce, tag) =
aes_256_gcm::seal(&user_key, plaintext);
db.insert(ciphertext, nonce, tag);
Encryption in transit
All client↔server traffic runs over TLS 1.2+. On top of that, the gRPC layer wraps each session with an ephemeral RSA-OAEP key exchange so payloads are protected even on compromised middleboxes. MCP clients can additionally pin the server certificate via JAUMEMORY_GRPC_PINNED_SHA256.
Backups
Backups inherit the same per-user encryption. Restoring a database snapshot does not restore plaintext: the master key must still be present and each user's key still has to be derived. Audit logs are encrypted with separate keys from memory content.
Database isolation: FORCE row-level security
PostgreSQL itself enforces that one user cannot see another user's rows. We don't ask the application to "remember" to filter by user_id; the database refuses to return foreign rows even if the application forgets.
FORCE, not just ENABLE
More than 25 user-data tables run with FORCE ROW LEVEL SECURITY. The FORCE qualifier matters: it means even the table-owner role cannot bypass the policy. If our application database role were stolen tomorrow, it still could not read other users' data.
ALTER TABLE memories ENABLE ROW LEVEL SECURITY;
ALTER TABLE memories FORCE ROW LEVEL SECURITY;
CREATE POLICY memories_isolation ON memories
USING (user_id = current_setting('app.current_user_id')::uuid);
Per-connection identity binding
Every database connection sets app.current_user_id from the validated authentication context before any query runs. Background tasks set the GUC for the user whose work they're processing, never a global "service" identity.
Tightly scoped escape hatches
The handful of legitimately cross-user paths (login lookup before identity is known, scheduler claim queue, session boot-load) are implemented as SECURITY DEFINER functions with explicit EXECUTE grants and narrow signatures. They are audited and CI-tested with cross-user psql guards on every build.
Authentication
Passwords, sessions, and second factors are handled with current best practice, not 2015-vintage advice.
Password hashing
Passwords are hashed with Argon2id at m=64MB, t=3, p=4, matching 2025 OWASP guidance. On a missed username we still run a decoy Argon2 verify so login timing does not reveal whether an account exists.
Session and refresh tokens
Sessions are JWTs with a per-token jti tracked in an active_tokens table, so logout and admin revocation are real, not hopeful. Refresh tokens use a deterministic SHA-256 lookup hash + Argon2id storage hash, and rotation is atomic: a refresh either revokes the old token and issues a new one, or it fails entirely; partial states are rejected.
Multi-factor authentication
TOTP (RFC 6238) with the shared secret encrypted at rest, plus one-time-use SHA-256-hashed backup recovery codes. New accounts can be given a configurable MFA grace window, and known-good devices can be remembered via signed device fingerprints to skip prompts on familiar hardware.
Constant-time comparisons
Recovery tokens, TOTP codes, and backup codes are compared with constant_time_eq, wrapped in std::hint::black_box to defeat aggressive LLVM optimizations that would otherwise short-circuit the comparison and leak timing.
// Even the optimizer can't shortcut this
let ok = black_box(
constant_time_eq(provided.as_bytes(), expected.as_bytes())
);
Network protections
Trustworthy client IP
We parse X-Forwarded-For right-to-left and only trust hops whose immediate peer is in TRUSTED_PROXY_CIDRS. Spoofed XFF headers from arbitrary clients are ignored, so rate limits and IP allowlists actually work.
Per-user IP allowlist
Users can pin their account to a set of IP ranges. Enforcement is gated by ENFORCE_USER_IP_ALLOWLIST so it can be staged safely. Admins have a 24-hour emergency override for genuine lock-outs, and every use is logged.
gRPC certificate pinning
MCP clients can opt in to pinning the backend's TLS certificate by setting JAUMEMORY_GRPC_PINNED_SHA256. A mis-issued or attacker-controlled certificate, even from a trusted CA, will fail the handshake.
gRPC admin authentication
Admin gRPC services derive identity from the validated AuthContext, never from client-supplied headers. The auth layer strips any incoming x-user-id header before the handler runs, and admin role membership is checked against the user_roles table on every privileged call.
Account recovery
Password reset is the most-attacked surface in any application. We treat it that way.
Real email, real care
Recovery email is sent over real SMTP (Ionos infrastructure) via lettre with STARTTLS. Recovery URLs are never written to logs: tracing for the recovery path is downgraded to debug with body fields stripped.
Rate limiting and enumeration resistance
Recovery initiation is rate-limited per email (1 / 5 min) and per IP (1 / 2s with a small burst). Responses to "user exists" and "user doesn't exist" return identical bodies in roughly identical time, so attackers can't probe for valid accounts.
Atomic credential revocation
A successful password reset revokes all refresh tokens, active sessions, and API keys in a single transaction. There is no window where an attacker who phished an old session can keep using it after the legitimate user resets.
Audit logging
Every authentication event, admin action, MFA setup or disable, IP allowlist change, and credential rotation is recorded to an append-only audit log. Audit rows are encrypted at rest with keys distinct from memory content keys, so an audit breach does not become a memory breach (and vice versa). Retention is governed by explicit, documented policy, not by whoever last ran a cleanup script.
MCP client credential cache
The MCP server caches your auth credentials locally so you don't re-authenticate on every Claude session. We treat that cache as a sensitive file:
OS keychain first
When available, credentials are stored in the operating system's keychain via keytar. The encrypted file is a fallback, not the default.
AES-256-GCM with PBKDF2
The fallback file is encrypted with AES-256-GCM using a key derived via PBKDF2-SHA256 with 100,000 iterations. Tampered files fail the GCM auth tag and are rejected.
Locked-down storage
The cache directory (~/.config/jaumemory-mcp/ on Unix, %APPDATA%\jaumemory-mcp\ on Windows) is created with 0o700. On Windows we use icacls to remove inheritance and grant access only to the current user. Files are written with atomic O_EXCL creation to close umask races.
Privacy & data rights
GDPR Rights
Full support for data access, portability, correction, and deletion rights.
CCPA Rights
California residents can access, delete, and opt out of data sharing; we never sell data.
Data Portability
Export everything you've stored at any time in standard formats (JSON, CSV).
Right to Delete
Delete individual memories or your entire account. Soft-deletes hard-delete after 30 days; account closure cascades through every per-user table.
Security contact
Found a vulnerability? We appreciate responsible disclosure and will work with you in good faith.
Email: security@jau.app