DEV Community

Cover image for How to Build a Secure MCP Server for AI Agents
Shola Jegede
Shola Jegede Subscriber

Posted on

How to Build a Secure MCP Server for AI Agents

OpenClaw proved that millions of developers will build with MCP first and think about security second. Here is how to not be that developer.

In this article, you will learn:

  • What OpenClaw and Moltbook revealed about the state of agentic AI security
  • Why most MCP servers are architecturally insecure by default
  • What "tool-level auth" means and why it is the right mental model for MCP security
  • How Kinde's own MCP server (shipped January 2026) handles scoped access as a real-world reference
  • How to set up a Kinde M2M application as the authorization layer for your MCP server
  • How to issue and validate scoped JWT tokens per tool category
  • How to write a Node.js MCP server that enforces permissions at the tool boundary
  • How to test that your auth layer actually holds under adversarial conditions

Let's dive in!

OpenClaw, Moltbook, and the Security Wake-Up Call

In late January 2026, two things happened simultaneously that changed how the developer community thinks about agentic AI security.

OpenClaw — an open-source self-hosted AI agent formerly called Clawdbot and briefly Moltbot — crossed 180,000 GitHub stars in a week. It turned any messaging app (WhatsApp, Telegram, Discord, Signal) into an AI agent interface, extended by a Skills system built entirely on MCP servers. Security researchers scanning the internet found over 1,800 exposed OpenClaw instances leaking API keys, chat histories, and account credentials. Cisco's AI security team tested a third-party OpenClaw skill from the ClawHub marketplace and found it performed data exfiltration and prompt injection without user awareness. One of OpenClaw's own maintainers, known as Shadow, warned on Discord that the project was "far too dangerous" for anyone who could not understand command-line basics.

Moltbook — a Reddit-style social network built by Matt Schlicht exclusively for AI agents — launched on January 28 and hit 1.6 million registered agents by February. It was built via vibe coding and contained a misconfigured Supabase database that granted full read and write access to its data — exposing 1.5 million agents belonging to only 17,000 registered humans. Cybersecurity researchers at Vectra AI and PointGuard AI identified Moltbook as a vector for indirect prompt injection: a malicious post on Moltbook could cascade into every MCP tool an agent had access to. Moltbook was acquired by Meta on March 10, 2026.

The pattern that connected both incidents was the same: agents were granted sweeping, unscoped access to tools. No authentication. No per-tool permissions. No way to answer the question "which agent did this and what were they authorized to do?" A static API key — if there was one at all — gave full access to everything.

Security researcher Itamar Golan (founder of Prompt Security, now part of SentinelOne) put it plainly in a VentureBeat interview: treat agents as production infrastructure, not a productivity app — least privilege, scoped tokens, allowlisted actions, strong authentication on every integration, and auditability end-to-end.

This article is about building exactly that.

Timeline showing OpenClaw going viral (Jan 2026) → Moltbook launching (Jan 28) → 1,800+ exposed OpenClaw instances found → Moltbook Supabase breach → Meta acquires Moltbook (Mar 10).

Why Most MCP Servers Are Architecturally Insecure

The MCP spec (finalized in late 2024, updated through 2025) mandates OAuth 2.1 with PKCE for public remote servers. But the ecosystem reality is starkly different. A 2026 survey of production MCP servers found that 53% rely on static API keys and only 8.5% use OAuth. The rest have no authentication at all.

This is not because developers are careless. It is because the default path is the insecure path. The most commonly copied MCP server examples use static bearer tokens or no auth. The "get something working" version of MCP has no concept of which agent is calling or what it is allowed to do. And when 180,000 developers adopt OpenClaw and start wiring up Skills, they inherit those insecure defaults at scale.

The specific problems with unscoped MCP access are worth naming precisely, because they map directly to what happened with OpenClaw and Moltbook:

No tool-level permissions. A token that grants access to your MCP server grants access to every tool on it. An agent authorized to read files can also delete them. An agent authorized to query a database can also drop tables. There is no granularity.

No agent identity. When five different OpenClaw instances connect to your MCP server, can you tell them apart? Can you see which user delegated authority to each one? With a static API key, the answer is no. All you know is that something with the key made a request.

Prompt injection cascades. Because agents are granted broad tool access, a successful prompt injection attack has a large blast radius. The Moltbook incident demonstrated this: a malicious post in an agent's context could instruct the agent to call tools it had no business calling — and because tools were unscoped, the agent could comply.

No revocation granularity. If you need to cut off one agent's access, you have to rotate the key for everyone.

What you need instead is a model where each agent has a cryptographically verified identity, each token carries specific scopes that enumerate exactly which tools the agent can call, and every tool call is validated against those scopes before execution. That is what Kinde's own MCP server — shipped in January 2026 — demonstrates in production.

Two architecture diagrams side by side. LEFT:

The Kinde MCP Server as a Reference Model

Kinde shipped its own MCP server in January 2026. It lets AI assistants like Claude Code connect to your Kinde business and manage users, organizations, roles, and permissions through natural language — things like "create a new organization for Acme Corp" or "list all users with the admin role."

The security model it uses is the right reference for anyone building their own MCP server:

M2M (Machine-to-Machine) applications are the identity primitive for agents. Not user accounts. Not API keys. Dedicated M2M applications that have their own client ID and secret, exist as first-class entities in Kinde, and can be granted or revoked access independently of human users.

Scopes enumerate exact permissions. The Kinde MCP server defines specific scopes for each category of operation — scopes for reading users, different scopes for writing users, separate scopes for managing organizations. An M2M application that has not been granted a scope cannot call the tools that require it. The authorization decision happens at the token issuance level, not at runtime guesswork.

JWT validation on every request. Tokens are short-lived (one hour by default for M2M), signed with Kinde's private key, and verified against Kinde's public JWKS endpoint on every inbound request. A token cannot be forged, replayed beyond its expiry, or accepted by a different server than the one it was issued for.

Revocability. To cut off an agent's access, you revoke or remove the M2M application in Kinde. It stops working immediately. No key rotation, no impact on other agents.

This is the pattern you should apply to your own MCP server. The steps below show you exactly how to implement it.

Kinde dashboard MCP Server section — showing the setup page or the operations/scopes list that Kinde's own MCP server exposes (e.g., list_users scope, create_organization scope, manage_roles scope).

Step #1: Create a Kinde M2M Application for Your MCP Server

Before writing any server code, set up the authorization layer in Kinde. This takes about three minutes.

Create a free Kinde account at kinde.com if you do not have one. Then navigate to SettingsApplicationsAdd application and select Machine to machine.

Kinde Settings > Applications page showing the

Give it a descriptive name like My MCP Server Agent. Once created, open the application details and note down:

  • Client ID — the agent's unique identity
  • Client Secret — the credential used to obtain tokens (treat this like a password)
  • Token endpointhttps://YOUR_DOMAIN.kinde.com/oauth2/token
  • JWKS endpointhttps://YOUR_DOMAIN.kinde.com/.well-known/jwks.json

Now register your MCP server as an API in Kinde. Navigate to APIsAdd API. Give it a name (e.g. My MCP Server) and an audience identifier (e.g. https://mcp.yourapp.com). This audience value will be embedded in every token issued for your server — your server checks it on every request to ensure the token was meant for it specifically, preventing token passthrough attacks.

Step #2: Define Scopes for Tool-Level Permissions

This is the step most MCP servers skip entirely, and it is the most important one.

In your Kinde API definition (under APIs → select your API → Scopes), define one scope per logical group of tools. Do not define one global scope — that defeats the purpose. Think about the categories of capability your MCP server exposes and create a scope for each.

Kinde API scopes page showing several scopes defined for the MCP server, e.g., tools:read, tools:write, admin:users, admin:orgs. Each scope has a name and description visible

For a hypothetical MCP server that manages a SaaS product's users and data, the scopes might look like this:

Scope Tools it authorizes Who gets it
users:read get_user, list_users, search_users Read-only agents, analytics pipelines
users:write create_user, update_user, invite_user CRM agents, onboarding automations
users:delete delete_user, suspend_user Admin agents only
data:read query_records, export_data Reporting agents
data:write create_record, update_record Integration agents
admin All tools Trusted internal agents only

Now grant your M2M application the scopes it actually needs. Navigate to your M2M application → APIs → select your API → check only the scopes this specific agent should have. An agent that only needs to read users gets users:read. It cannot request a token with users:delete even if it tries — Kinde will not issue it.

Kinde M2M application details showing the API scopes section with some scopes checked (users:read, data:read) and others unchecked (users:delete, admin

Terrific! The authorization policy is now defined. Time to build the server.

Step #3: Build the MCP Server with Token Validation

Now write the MCP server itself. This is a Node.js server using the @modelcontextprotocol/sdk package. The key additions over a basic MCP server are the JWT validation middleware and the per-tool scope enforcement.

First, install the dependencies:

npm init -y
npm install @modelcontextprotocol/sdk jwks-rsa jsonwebtoken express
Enter fullscreen mode Exit fullscreen mode

Create the server file:

// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express, { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
import { z } from "zod";

// ─── Configuration ──────────────────────────────────────────────────────────

const KINDE_DOMAIN = process.env.KINDE_DOMAIN!; // e.g. "yourapp.kinde.com"
const MCP_AUDIENCE = process.env.MCP_AUDIENCE!;  // e.g. "https://mcp.yourapp.com"

// JWKS client — fetches Kinde's public signing keys
// Used to verify that incoming tokens were genuinely signed by Kinde
const jwks = jwksClient({
  jwksUri: `https://${KINDE_DOMAIN}/.well-known/jwks.json`,
  cache: true,
  cacheMaxEntries: 5,
  cacheMaxAge: 600_000, // 10 minutes
});

// ─── JWT Validation ──────────────────────────────────────────────────────────

interface TokenPayload {
  sub: string;           // agent identity (M2M app client ID)
  scp?: string;          // space-separated scopes
  permissions?: string[]; // Kinde also surfaces permissions here
  aud: string | string[];
  iss: string;
  exp: number;
}

async function validateToken(authHeader: string | undefined): Promise<TokenPayload> {
  if (!authHeader?.startsWith("Bearer ")) {
    throw new Error("Missing or malformed Authorization header");
  }

  const token = authHeader.slice(7);

  // Decode the header to find the key ID (kid)
  const decoded = jwt.decode(token, { complete: true });
  if (!decoded || typeof decoded === "string") {
    throw new Error("Invalid token format");
  }

  const kid = decoded.header.kid;
  if (!kid) {
    throw new Error("Token missing key ID (kid)");
  }

  // Fetch the public key from Kinde's JWKS endpoint
  const signingKey = await jwks.getSigningKey(kid);
  const publicKey = signingKey.getPublicKey();

  // Verify the token — this checks signature, expiry, issuer, and audience
  const payload = jwt.verify(token, publicKey, {
    algorithms: ["RS256"],
    issuer: `https://${KINDE_DOMAIN}`,
    audience: MCP_AUDIENCE, // prevents token passthrough attacks
  }) as TokenPayload;

  return payload;
}

// ─── Scope Enforcement ───────────────────────────────────────────────────────

function requireScope(requiredScope: string) {
  return (payload: TokenPayload): void => {
    // Kinde puts scopes in the `scp` claim as a space-separated string
    const tokenScopes = payload.scp?.split(" ") ?? [];

    // Also check the `permissions` array (Kinde's permission system)
    const permissions = payload.permissions ?? [];

    const hasScope =
      tokenScopes.includes(requiredScope) ||
      tokenScopes.includes("admin") ||
      permissions.includes(requiredScope) ||
      permissions.includes("admin");

    if (!hasScope) {
      throw new Error(
        `Insufficient permissions. Required: ${requiredScope}. ` +
        `Token has: ${tokenScopes.join(", ") || "none"}`
      );
    }
  };
}

// ─── Auth Middleware ──────────────────────────────────────────────────────────

// Store the validated token payload on the request object
// so tool handlers can access it without re-validating
declare global {
  namespace Express {
    interface Request {
      agentToken?: TokenPayload;
    }
  }
}

async function authMiddleware(req: Request, res: Response, next: NextFunction) {
  try {
    const payload = await validateToken(req.headers.authorization);
    req.agentToken = payload;
    console.log(`Agent authenticated: ${payload.sub} with scopes: ${payload.scp}`);
    next();
  } catch (err) {
    console.warn(`Auth failed: ${(err as Error).message}`);
    res.status(401).json({
      error: "unauthorized",
      message: (err as Error).message,
    });
  }
}

// ─── MCP Server Definition ────────────────────────────────────────────────────

const server = new McpServer({
  name: "secure-mcp-server",
  version: "1.0.0",
});

// Tool #1: get_user — requires users:read scope
server.tool(
  "get_user",
  "Retrieve a user by their ID",
  {
    user_id: z.string().describe("The unique identifier of the user to retrieve"),
  },
  async ({ user_id }, { _meta }) => {
    // Access the validated token from the request context
    // The token was validated and attached by authMiddleware before this handler runs
    const token = (_meta as any)?.agentToken as TokenPayload;
    requireScope("users:read")(token);

    // Your actual business logic here
    // This is a placeholder — replace with real data fetching
    return {
      content: [
        {
          type: "text" as const,
          text: JSON.stringify({
            id: user_id,
            email: "user@example.com",
            name: "Jane Smith",
            created_at: "2025-01-15T10:30:00Z",
          }),
        },
      ],
    };
  }
);

// Tool #2: create_user — requires users:write scope
server.tool(
  "create_user",
  "Create a new user account",
  {
    email: z.string().email().describe("Email address for the new user"),
    name: z.string().describe("Full name of the user"),
    role: z.enum(["admin", "member", "viewer"]).default("member"),
  },
  async ({ email, name, role }, { _meta }) => {
    const token = (_meta as any)?.agentToken as TokenPayload;
    requireScope("users:write")(token);

    // Log who created the user for audit purposes
    console.log(`User creation requested by agent: ${token.sub}`);

    // Your actual user creation logic here
    return {
      content: [
        {
          type: "text" as const,
          text: JSON.stringify({
            id: `usr_${Date.now()}`,
            email,
            name,
            role,
            created_by_agent: token.sub,
            created_at: new Date().toISOString(),
          }),
        },
      ],
    };
  }
);

// Tool #3: delete_user — requires users:delete scope (most restrictive)
server.tool(
  "delete_user",
  "Permanently delete a user account",
  {
    user_id: z.string().describe("ID of the user to delete"),
    reason: z.string().describe("Reason for deletion (required for audit trail)"),
  },
  async ({ user_id, reason }, { _meta }) => {
    const token = (_meta as any)?.agentToken as TokenPayload;

    // This scope is only granted to trusted admin agents
    // Read-only agents cannot call this tool even if they know it exists
    requireScope("users:delete")(token);

    console.log(
      `AUDIT: User ${user_id} deleted by agent ${token.sub}. Reason: ${reason}`
    );

    return {
      content: [
        {
          type: "text" as const,
          text: JSON.stringify({
            deleted: true,
            user_id,
            deleted_by: token.sub,
            reason,
            timestamp: new Date().toISOString(),
          }),
        },
      ],
    };
  }
);

// ─── HTTP Server ──────────────────────────────────────────────────────────────

const app = express();
app.use(express.json());

// The auth middleware runs on every MCP request before the tool handlers
app.post("/mcp", authMiddleware, async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });

  // Pass the validated token into the MCP request context
  // so tool handlers can access it via _meta.agentToken
  (req as any).agentToken = req.agentToken;

  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

// Protected resource metadata endpoint — required for OAuth 2.1 discovery
// MCP clients fetch this to learn where to get tokens
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
  res.json({
    resource: MCP_AUDIENCE,
    authorization_servers: [`https://${KINDE_DOMAIN}`],
    scopes_supported: [
      "users:read",
      "users:write",
      "users:delete",
      "data:read",
      "data:write",
      "admin",
    ],
    bearer_methods_supported: ["header"],
  });
});

const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => {
  console.log(`Secure MCP Server running on port ${PORT}`);
  console.log(`Protected resource metadata: http://localhost:${PORT}/.well-known/oauth-protected-resource`);
});
Enter fullscreen mode Exit fullscreen mode

Terminal showing the MCP server running, with log output like

Step #4: How Agents Obtain Tokens

Your MCP server is now a protected resource. To call it, an agent needs a token. Here is how an OpenClaw-style agent — or any MCP client — would obtain one using Kinde's client credentials flow:

// agent-token.ts
// This code runs in the AGENT (the MCP client), not in the MCP server

async function getAgentToken(): Promise<string> {
  const tokenResponse = await fetch(
    `https://${process.env.KINDE_DOMAIN}/oauth2/token`,
    {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "client_credentials",
        client_id: process.env.KINDE_M2M_CLIENT_ID!,
        client_secret: process.env.KINDE_M2M_CLIENT_SECRET!,
        audience: process.env.MCP_AUDIENCE!, // your MCP server's audience
        scope: "users:read data:read", // only request what this agent needs
      }),
    }
  );

  if (!tokenResponse.ok) {
    const error = await tokenResponse.json();
    throw new Error(`Token request failed: ${JSON.stringify(error)}`);
  }

  const { access_token } = await tokenResponse.json();
  return access_token;
}

// Then use the token in every MCP request
async function callMcpTool(tool: string, params: object) {
  const token = await getAgentToken();

  const response = await fetch("https://mcp.yourapp.com/mcp", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${token}`, // Kinde-issued JWT
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      method: "tools/call",
      params: { name: tool, arguments: params },
      id: 1,
    }),
  });

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Note: Tokens issued via client credentials are valid for one hour by default in Kinde. Agents should cache the token and only request a new one when it is close to expiry — not on every tool call. Check the expires_in field in the token response and refresh when there are 60 seconds or fewer remaining.

Step #5: Configure an OpenClaw Skill With Proper Auth

To connect a properly authenticated MCP server to an OpenClaw agent, you configure the skill in openclaw.json with the token endpoint details. Here is what a secure skill configuration looks like:

{
  "plugins": {
    "entries": {
      "my-secure-tool": {
        "enabled": true,
        "type": "mcp",
        "transport": "streamable-http",
        "url": "https://mcp.yourapp.com/mcp",
        "auth": {
          "type": "oauth2",
          "flow": "client_credentials",
          "token_endpoint": "https://YOUR_DOMAIN.kinde.com/oauth2/token",
          "client_id": "your_m2m_client_id",
          "client_secret_env": "MY_TOOL_CLIENT_SECRET",
          "audience": "https://mcp.yourapp.com",
          "scopes": ["users:read", "data:read"]
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: client_secret_env points to an environment variable rather than embedding the secret inline — critical for anyone publishing skill configurations or committing them to version control. The secret itself lives in the environment, not in the config file.

This configuration gives your agent the specific scopes it needs and nothing more. If someone performs a prompt injection attack via Moltbook and tries to get this agent to call delete_user, the token does not have the users:delete scope — the MCP server rejects the call before it can execute.

Putting It All Together

Here is the complete security architecture you have built:

┌─────────────────────────────────────────────────────────────────┐
│                         Kinde                                   │
│                                                                 │
│  M2M Application (Agent Identity)                               │
│  ├── Client ID: m2m_abc123                                      │
│  ├── Granted scopes: users:read, data:read                      │
│  └── NOT granted: users:write, users:delete, admin              │
│                                                                 │
│  JWKS Endpoint: /well-known/jwks.json (public key verification) │
│  Token Endpoint: /oauth2/token (client credentials flow)        │
└───────────────────────────────┬─────────────────────────────────┘
                                │ issues signed JWT (1hr TTL)
                                │ with scopes: users:read data:read
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Agent (OpenClaw)                           │
│                                                                 │
│  Holds JWT in memory (never stored on disk)                     │
│  Sends: Authorization: Bearer <JWT> on every MCP request        │
└───────────────────────────────┬─────────────────────────────────┘
                                │ HTTPS POST /mcp
                                │ Authorization: Bearer <JWT>
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Secure MCP Server                            │
│                                                                 │
│  1. authMiddleware: validates JWT signature via JWKS            │
│     → checks issuer, audience, expiry                           │
│                                                                 │
│  2. Per-tool scope check: requireScope("users:read")            │
│     → tools/get_user ✓ (scope present)                          │
│     → tools/delete_user ✗ (scope missing → 401)                 │
│                                                                 │
│  3. Audit log: agent ID, tool called, timestamp                 │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Clean visual version of the ASCII architecture above — showing Kinde (auth layer) on the left, Agent (OpenClaw) in the middle, Secure MCP Server on the right. Arrows show the token flow. The scope check at the tool boundary is highlighted.

The security properties you now have that OpenClaw's default Skills did not:

Property Insecure MCP (OpenClaw default) Secure MCP (with Kinde)
Agent identity Anonymous / shared API key Cryptographic JWT (sub claim = M2M app ID)
Token forgability API key can be shared/leaked RS256 JWT signed by Kinde — cannot be forged
Tool-level permissions All tools accessible to all callers Per-tool scope enforcement
Prompt injection blast radius Agent can call any tool if injected Injected instruction can only call scoped tools
Token expiry API keys never expire 1-hour TTL — breach window is bounded
Revocation Must rotate key for all agents Revoke one M2M app, others unaffected
Audit trail None Agent ID + scope logged on every tool call

Testing Your Security Layer

Before deploying, verify these four scenarios:

Test 1 — Valid token, allowed scope. Call get_user with a token that has users:read. You should receive the user data. This confirms the happy path works.

Test 2 — Valid token, insufficient scope. Call delete_user with a token that only has users:read. You should receive a 401 with a message like "Insufficient permissions. Required: users:delete." This confirms scope enforcement works.

Test 3 — Expired token. Manually modify the exp claim of a token or wait for it to expire, then attempt a call. You should receive a 401 with a JWT expiry error. This confirms token lifecycle enforcement works.

Test 4 — Wrong audience. Use a token issued for a different API (different aud claim) and attempt a call to your MCP server. You should receive a 401 with an audience mismatch error. This confirms that token passthrough attacks are blocked.

# Test 2 example — calling a tool without the required scope
curl -X POST https://your-mcp-server.com/mcp \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $READ_ONLY_TOKEN" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "delete_user",
      "arguments": {
        "user_id": "usr_123",
        "reason": "Test deletion attempt"
      }
    },
    "id": 1
  }'

# Expected response:
# HTTP 401 Unauthorized
# { "error": "unauthorized", "message": "Insufficient permissions. Required: users:delete. Token has: users:read" }
Enter fullscreen mode Exit fullscreen mode

Amazing! Your MCP server is now hardened against the attack patterns that took down OpenClaw deployments and turned Moltbook into a security incident.

Conclusion

In this article, you built a secure MCP server with scoped, tool-level authentication — the approach that the OpenClaw and Moltbook incidents showed was critically missing from most agentic AI deployments. Your server now knows who every connecting agent is, what they are allowed to do, and rejects anything outside those boundaries at the tool boundary before execution.

The key insight from Kinde's own MCP server — shipped in January 2026 as a working reference implementation — is that security for AI agents is not fundamentally different from security for any other kind of software. M2M identities, scoped tokens, short TTLs, and JWT validation are not new concepts. What is new is applying them consistently to MCP servers, which the ecosystem has been slow to do.

Agentic AI is not going away. OpenClaw proved that. The question is whether the infrastructure it runs on is built securely. That starts with the MCP server you control.

Kinde is free for up to 10,500 monthly active users, no credit card required. Create your account at kinde.com and start treating your agents as the production infrastructure they are.

Top comments (0)