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

  1. Use an API key scoped to the project you are querying, or one without a project scope
  2. Change the request's projectId to match the key's scoped project (req.authContext.projectId)
  3. 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

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


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)