DEV Community

Luca Moretti
Luca Moretti

Posted on

How to Secure Your MCP Server's API Keys (With Working Demo)

The Problem Every MCP Developer Ignores

You build an MCP server. It needs a GitHub token. Maybe an OpenAI key. Where do they go?

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["server.js"],
      "env": {
        "GITHUB_TOKEN": "ghp_XXXXXXXXXXXX",
        "OPENAI_API_KEY": "sk-XXXXXXXXXXXX"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Plaintext. In a JSON file. On disk. Possibly committed to git.

This is how most MCP servers handle secrets today. And it's a ticking time bomb.

A Better Way: Encrypted Vault + Runtime Decryption

I built a demo MCP server that does it differently. Instead of reading secrets from env vars, it pulls them from Janee — an encrypted vault designed for MCP servers.

The flow:

Claude Desktop → MCP Protocol → Your Server → Janee Vault → API Call
                                                   ↑
                                         AES-256-GCM encrypted
                                         Decrypted only at runtime
Enter fullscreen mode Exit fullscreen mode

No plaintext secrets on disk. Ever.

Try It Yourself (5 minutes)

git clone https://github.com/lucamorettibuilds/janee-mcp-demo
cd janee-mcp-demo
npm install

# Set up the encrypted vault
npx janee init          # Creates encrypted vault
npx janee add github    # Stores your GitHub token (encrypted)
npx janee add openai    # Stores your OpenAI key (encrypted)

# Run it
export JANEE_MASTER_PASSWORD="your-password"
npm start
Enter fullscreen mode Exit fullscreen mode

The server exposes three MCP tools:

  • github_create_issue — creates GitHub issues using vault credentials
  • openai_complete — calls OpenAI using vault credentials
  • list_secrets — shows what's in the vault (names only, never values)

How the Code Works

The secret sauce is a tiny integration layer (src/secrets.js):

import { execSync } from "child_process";

export async function getSecret(service, field) {
  try {
    const result = execSync(
      `npx janee get ${service} ${field} 2>/dev/null`,
      { encoding: "utf-8", env: { ...process.env } }
    ).trim();
    return result || null;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Then in your MCP tool handler, instead of process.env.GITHUB_TOKEN:

const token = await getSecret("github", "token");
Enter fullscreen mode Exit fullscreen mode

That's it. The secret is decrypted from the vault at runtime, used for the API call, and never persisted in memory longer than needed.

Claude Desktop Config

{
  "mcpServers": {
    "janee-demo": {
      "command": "node",
      "args": ["/path/to/janee-mcp-demo/src/server.js"],
      "env": {
        "JANEE_MASTER_PASSWORD": "your-master-password"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice: one password protects all your API keys. No more scatter-shot env vars.

Why Not Just Use .env Files?

.env files Janee vault
Storage Plaintext AES-256-GCM encrypted
Access control None Session-based with TTL
Rotation Manual find-and-replace janee add service (overwrites)
Audit None Built-in logging
Git safety Needs .gitignore discipline Vault file is safe to commit

Adding Your Own Services

The demo ships with GitHub and OpenAI, but you can add anything:

npx janee add stripe
npx janee add anthropic  
npx janee add postgres
Enter fullscreen mode Exit fullscreen mode

Then use getSecret("stripe", "api_key") in your tool handler.

Get the Code

If you're building MCP servers, give your secrets the respect they deserve. ⭐ Janee if it's useful.

Top comments (0)