- Password hashing: scrypt via
node:cryptowith timing-safe verification - JWT tokens: HS256 algorithm (explicitly specified in sign/verify), httpOnly cookies with
SameSite=StrictandSecure(configurable viaserver.cookieSecure, defaults to true unlessNODE_ENV=development) - API keys: timing-safe comparison via
crypto.timingSafeEqualfor all key checks - Refresh tokens: scoped to
/api/auth/refreshpath, validated against current user config
See Authentication for full details.
SameSite=Stricton all auth cookies — cookies are only sent on same-origin requests- No additional CSRF tokens needed —
SameSite=Strictis sufficient for cookie-based auth - API key authentication (Bearer header) is inherently CSRF-proof
- Auth cookies are
httpOnly— not accessible viadocument.cookieor JavaScript X-Content-Type-Options: nosniffheader on all responses- Attachment downloads use
Content-Disposition: attachmentto prevent inline rendering
All API key checks use constant-time comparison:
crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))This applies to:
- User API keys (
users.<id>.apiKey) - Embedding API key (
server.embeddingApi.apiKey) - Remote embedding API key (
embedding.remoteApiKey)
Remote embedding URLs are validated to only allow http: and https: protocols. This prevents SSRF attacks via file:, data:, javascript:, or other URL schemes.
Validated at two levels:
-
REST validation (
src/api/rest/validation.ts):attachmentFilenameSchemarejects filenames containing:- Path separators (
/,\) - Parent directory traversal (
..) - Null bytes and control characters
- Enforces length limits (1–255 chars)
- Path separators (
-
MCP tool validation: attachment tools also validate filenames with the same Zod schema (defense-in-depth)
-
MCP add-attachment tools: validate file path with
fs.statSync()— reject directories, enforce 50 MB upload limit
Graph managers enforce hard limits on attachments (defined in src/graphs/attachment-types.ts):
- 10 MB maximum per individual attachment file
- 20 maximum attachments per entity (note, task, or skill)
- Write-time sanitization (
src/lib/file-mirror.ts):sanitizeFilename()strips:- Null bytes
..sequences- Path separators (via
path.basename)
Attachment downloads use RFC 5987 encoding:
Content-Disposition: attachment; filename*=UTF-8''encoded-filename
X-Content-Type-Options: nosniff
Ensures correct handling of Unicode filenames and prevents MIME sniffing.
All responses include:
X-Content-Type-Options: nosniffX-Frame-Options: DENY
Configurable via server.corsOrigins:
server:
corsOrigins:
- "http://localhost:5173"
- "https://app.example.com"Security recommendation: Always set
corsOriginswhen users are configured. Without it, all origins are allowed withcredentials: true. WhileSameSite=Strictcookies mitigate most cross-origin attacks in modern browsers, explicit origin allowlisting provides defense-in-depth.
MCP endpoints (/mcp/{projectId}) are authenticated via API key when users are configured. Previously, MCP was unprotected. The API key is sent via the Authorization: Bearer <apiKey> header, and comparison uses crypto.timingSafeEqual (same as REST API key checks).
MCP clients that support OAuth (e.g., Claude.ai) can authenticate via the OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange, S256 method). This avoids sharing static API keys with third-party clients.
The server publishes RFC 8414 metadata at GET /.well-known/oauth-authorization-server, advertising all supported endpoints, grant types, and code_challenge_methods_supported: ["S256"].
- The MCP client redirects the user's browser to the authorization endpoint (
/ui/auth/authorize) withresponse_type=code,code_challenge(SHA-256 of the verifier, base64url-encoded),code_challenge_method=S256,client_id,redirect_uri, and optionalstate. - The consent page at
/ui/auth/authorizeshows the requesting service's hostname so the user knows which service is requesting access. The user must already hold a valid session cookie (i.e., be logged in to the UI). - After the user consents, the frontend POSTs to
POST /api/oauth/authorize. The server verifies the active session JWT (type: accessortype: oauth_access), generates a 32-byte random authorization code, stores it in the session store (keyed asauthcode:<code>) with a 10-minute TTL, and returns aredirectUrlfor the client callback. - The client exchanges the code at
POST /oauth/tokenwithgrant_type=authorization_code,code,redirect_uri,client_id, andcode_verifier. The server:- Retrieves and immediately deletes the code entry from the session store (single-use enforcement).
- Verifies
redirect_urimatches exactly. - Verifies PKCE:
base64url(sha256(code_verifier)) === code_challenge. - Issues an access/refresh token pair on success.
- Subsequent requests use the access token as
Authorization: Bearer <token>. Refresh tokens are exchanged atPOST /oauth/tokenwithgrant_type=refresh_token.
| Grant type | Use case |
|---|---|
authorization_code |
Interactive MCP clients (Claude.ai, browser-based tools) |
client_credentials |
Programmatic/machine clients using a user's apiKey as client_secret |
refresh_token |
Renewing an expired access token without re-authentication |
OAuth flows introduce two additional JWT type values distinct from the cookie-based UI session tokens:
| Type | Description |
|---|---|
access |
UI session access token (cookie-based) |
refresh |
UI session refresh token (cookie-based, scoped to /api/auth/refresh) |
oauth_access |
OAuth access token (Bearer header) |
oauth_refresh |
OAuth refresh token; accepted only on POST /oauth/token with grant_type=refresh_token |
This separation ensures OAuth Bearer tokens cannot be used to call the UI session refresh endpoint and vice versa.
Authorization codes are stored in a SessionStore abstraction (src/lib/session-store.ts) rather than in-process memory or the JWT itself. Two implementations are provided:
| Implementation | Class | Notes |
|---|---|---|
| In-memory | MemorySessionStore |
Default; uses Map with setTimeout-based expiry. Not suitable for multi-instance deployments. |
| Redis | RedisSessionStore |
Wraps a redis client, uses SET … EX for atomic TTL. Safe for clustered/multi-process deployments. |
The store is injected into createOAuthRouter() so the correct backend can be wired at startup.
| Endpoint | Description |
|---|---|
GET /api/oauth/userinfo |
Returns { sub, name, email } for a valid oauth_access Bearer token |
POST /api/oauth/introspect |
RFC 7662 token introspection — returns active, sub, token_type, exp, iat |
POST /api/oauth/revoke |
Stub; returns 200 for client compatibility |
POST /api/oauth/end-session |
Stub; returns 200 for client compatibility |
The readonly: true graph setting provides an additional layer of protection:
- MCP mutation tools are not registered (invisible to clients)
- REST mutation endpoints return 403
- UI hides write buttons
This is useful for graphs that should be searchable but not modifiable — e.g., a shared knowledge base that only admins update directly. Even if a user has rw access, readonly overrides it at the graph level.
5-level ACL with per-graph granularity. Resolution chain (first match wins):
graph.access[userId] → project.access[userId] → workspace.access[userId]
→ server.access[userId] → server.defaultAccess
Note: A graph-level
rwoverrides a server-leveldenybecause the chain uses first-match-wins. This is intentional for granular control — admins can deny by default and grant per-graph access.
When users are configured, unauthenticated requests are rejected with 401. The defaultAccess setting only applies to authenticated users not explicitly listed in any ACL level.
See Authentication for full details.
WebSocket connections (/api/ws) require a valid JWT session cookie (mgm_access). API-key-only clients (Bearer header) cannot connect to WebSocket — this is by design since WebSocket is intended for the browser UI.
Events are filtered server-side: each client only receives events for projects they have read access to.
- MCP HTTP sessions have configurable idle timeout (default: 30 min)
- JWT access tokens expire after configurable TTL (default: 15 min)
- JWT refresh tokens expire after configurable TTL (default: 7 days)
- OAuth access tokens share the same TTL as UI access tokens
- OAuth refresh tokens share the same TTL as UI refresh tokens
- OAuth authorization codes expire after 10 minutes and are single-use (deleted from the session store on first redemption)
- Each JWT request validates user still exists in config (revocation on user removal)
Refresh tokens (both cookie-based and OAuth) are not invalidated server-side after use. A stolen refresh token remains valid until expiry even after the legitimate user refreshes. For production deployments, use Redis as session store — this enables future server-side token blacklisting.
API keys are stored in plaintext in graph-memory.yaml. Protect this file with appropriate filesystem permissions (chmod 600). Do not commit it to version control. Consider mounting it read-only in Docker (:ro).
POST /api/oauth/revoke and POST /api/oauth/end-session are stubs that return 200 for client compatibility but do not actually invalidate tokens. Tokens remain valid until expiry.
MCP tool visibility is determined at session creation time. If ACL configuration changes while an MCP session is active, the session retains its original permissions until reconnection.