thedotmack/claude-mem · error
API key is scoped to a different project
Error message
API key is scoped to a different project
What it means
This error is thrown by the v1 server recall route when an API key is scoped to a specific project but the request targets a different projectId. The route captures req.authContext?.projectId as a projectScope and rejects any backend call whose projectId does not match, preventing cross-project data access with a project-scoped key.
Solutions
- Use an API key scoped to the project you are querying, or one without a project scope
- Change the request's projectId to match the key's scoped project (req.authContext.projectId)
- Issue a new unscoped/team-level API key via the server API key CLI if cross-project access is intended
Example fix
// before
fetch('/api/v1/recall', { body: JSON.stringify({ projectId: 'proj-b', query: 'x' }), headers: authWithKeyScopedToProjA })
// after
fetch('/api/v1/recall', { body: JSON.stringify({ projectId: 'proj-a', query: 'x' }), headers: authWithKeyScopedToProjA }) Defensive patterns
Strategy: validation
Validate before calling
const keyProject = req.authContext?.projectId ?? null;
if (keyProject && keyProject !== requested.projectId) {
throw new Error('API key is scoped to a different project');
} Type guard
function isProjectAllowed(keyProjectId: string | null | undefined, projectId: string): boolean {
return !keyProjectId || keyProjectId === projectId;
} Try / catch
try {
await recallSearch(params);
} catch (e) {
if (e.message === 'API key is scoped to a different project') {
res.status(403).json({ error: 'key project scope mismatch' });
} else throw e;
} Prevention
- Store the key's scoped projectId alongside the key and always send the matching projectId
- Prefer team-level unscoped keys for tooling that spans projects
- Write an integration test per key scope asserting cross-project calls fail
- Never hardcode projectId in shared request snippets; derive it from the auth context
When it happens
Trigger: Calling the recall/search endpoint with an API key whose authContext.projectId is set (non-null) while the request body supplies a projectId that differs from that scope.
Common situations: Developers reuse an API key minted for project A to query project B's memories; copying a working request and swapping only the projectId; a shared key distributed across teams that was created with a project scope.
Understand the failure class
Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.
Related errors
- agent_event source_id must belong to project_id and team_id
- auth_invalid
- BadRequest
- BadRequest
- Forbidden
AI-assisted analysis of thedotmack/claude-mem@d8bc9755e7 (2026-09-17).
Data as JSON: /api/errors/1ed58b205e7c2198.
Report an issue: GitHub.
Appendix: source
Thrown at src/server/routes/v1/ServerV1PostgresRoutes.ts:1036
});
},
));
// Remote authenticated MCP endpoint. The "secure MCP link" a user pastes
// into Claude Code (or any MCP client) to recall their cloud memory:
// claude mcp add --transport http claude-mem <base>/v1/mcp \
// --header "Authorization: Bearer cm_..."
// Same readAuth (memories:read) + team/project scoping + audit trail as
// /v1/search, so it reads identical data through identical guards. Stateless
// streamable-HTTP: one transport + server per request, bound to this key's team.
const mcpHandler = this.asyncHandler(async (req, res) => {
const teamId = this.requireTeamId(req, res);
if (!teamId) return;
const projectScope = req.authContext?.projectId ?? null;
const repo = new PostgresObservationRepository(this.options.pool);
const assertProjectAllowed = (projectId: string): void => {
if (projectScope && projectScope !== projectId) {
throw new Error('API key is scoped to a different project');
}
};
const backend: RecallBackend = {
search: async ({ projectId, query, limit }) => {
assertProjectAllowed(projectId);
const rows = await repo.search({ projectId, teamId, query, limit });
// Audit the read, same as POST /v1/search — the MCP path is no exception.
await this.auditWrite(req, 'observation.read', null, projectId, {
mode: 'search', via: 'mcp', query, limit,
resultCount: rows.length, observationIds: rows.map(o => o.id),
});
return rows.map(serializeObservation);
},
context: async ({ projectId, query, limit }) => {
assertProjectAllowed(projectId);
const rows = await repo.search({ projectId, teamId, query, limit });
await this.auditWrite(req, 'observation.read', null, projectId, {
mode: 'context', via: 'mcp', query, limit,View on GitHub (pinned to d8bc9755e7)