The Model Context Protocol (MCP) provides a powerful way for LLMs to interact with external tools. exposing an MCP server without security is like leaving the front door of your house wide open. This guide will walk you through securing a Node.js MCP server from the ground up using JWT.The Model Context Protocol (MCP) provides a powerful way for LLMs to interact with external tools. exposing an MCP server without security is like leaving the front door of your house wide open. This guide will walk you through securing a Node.js MCP server from the ground up using JWT.

Securing Your MCP Server: a Step-by-Step Guide

2025/09/16 10:00

The Model Context Protocol (MCP) provides a powerful, standardized way for LLMs to interact with external tools. But as soon as you move from a local demo to a real-world application, a critical question arises: How do you secure it?

\ Exposing an MCP server without security is like leaving the front door of your house wide open. Anyone could walk in and use your tools, access your data, or cause havoc.

\ This guide will walk you through securing a Node.js MCP server from the ground up using JSON Web Tokens (JWT). We'll cover authentication (who are you?) and authorization (what are you allowed to do?), with practical code samples based on this project that can be found at Azure-Samples/mcp-container-ts.

\

The Goal: From Unprotected to Fully Secured

Our goal is to take a basic MCP server and add a robust security layer that:

  1. Authenticates every request to ensure it comes from a known user.

  2. Authorizes the user, granting them specific permissions based on their role (e.g., admin vs. readonly).

  3. Protects individual tools, so only authorized users can access them.

    \

Why JWT is Perfect for MCP Security

JWT is the industry standard for securing APIs, and it's an ideal fit for MCP servers for a few key reasons:

  • Stateless: Each JWT contains all the information needed to verify a user. The server doesn't need to store session information, which makes it highly scalable—perfect for handling many concurrent requests from AI agents.

  • Self-Contained: A JWT can carry user details, their role, and specific permissions directly within its payload.

  • Tamper-Proof: JWTs are digitally signed. If a token is modified in any way, the signature becomes invalid, and the server will reject it.

  • Portable: A single JWT can be used to access multiple secured services, which is common in microservice architectures.

    \

Visualizing the Security Flow

For visual learners, this sequence diagram illustrates the complete authentication and authorization flow:

Complete authentication and authorization flow

\

A Note on MCP Specification Compliance!

It's important to note that this guide provides a practical, real-world implementation for securing an MCP server, but it does not fully implement the official MCP authorization specification.

\ This implementation focuses on a robust, stateless, and widely understood pattern using traditional JWTs and role-based access control (RBAC), which is sufficient for many use cases. However, for full compliance with the MCP specification, you would need to implement additional features. In a future post, we may explore how to extend our JWT implementation to fully align with the MCP specification.

\ We recommend staring the GitHub repository to stay updated and receive notifications about future improvements.

\

Step 1: Defining Roles and Permissions

Before writing any code, we must define our security rules. What roles exist? What can each role do? This is the foundation of our authorization system.

\ In our src/auth/authorization.ts file, we define UserRole and Permission enums. This makes our code clear, readable, and less prone to typos.

// src/auth/authorization.ts  export enum UserRole {   ADMIN = "admin",   USER = "user",   READONLY = "readonly", }  export enum Permission {   CREATE_TODOS = "create:todos",   READ_TODOS = "read:todos",   UPDATE_TODOS = "update:todos",   DELETE_TODOS = "delete:todos",   LIST_TOOLS = "list:tools", }  // This interface defines the structure of our authenticated user export interface AuthenticatedUser {   id: string;   role: UserRole;   permissions: Permission[]; }  // A simple map to assign default permissions to each role const rolePermissions: Record<UserRole, Permission[]> = {   [UserRole.ADMIN]: Object.values(Permission), // Admin gets all permissions   [UserRole.USER]: [     Permission.CREATE_TODOS,     Permission.READ_TODOS,     Permission.UPDATE_TODOS,     Permission.LIST_TOOLS,   ],   [UserRole.READONLY]: [Permission.READ_TODOS, Permission.LIST_TOOLS], }; 

\

Step 2: Creating a JWT Service

Next, we need a centralized service to handle all JWT-related logic: creating new tokens for testing and, most importantly, verifying incoming tokens. This keeps our security logic clean and in one place.

\ Here is the complete src/auth/jwt.ts file. It uses the jsonwebtoken library to do the heavy lifting.

// src/auth/jwt.ts  import * as jwt from "jsonwebtoken"; import {   AuthenticatedUser,   getPermissionsForRole,   UserRole, } from "./authorization.js";  // These values should come from environment variables for security const JWT_SECRET = process.env.JWT_SECRET!; const JWT_AUDIENCE = process.env.JWT_AUDIENCE!; const JWT_ISSUER = process.env.JWT_ISSUER!; const JWT_EXPIRY = process.env.JWT_EXPIRY || "2h";  if (!JWT_SECRET || !JWT_AUDIENCE || !JWT_ISSUER) {   throw new Error("JWT environment variables are not set!"); }  /**  * Generates a new JWT for a given user payload.  * Useful for testing or generating tokens on demand.  */ export function generateToken(   user: Partial<AuthenticatedUser> & { id: string } ): string {   const payload = {     id: user.id,     role: user.role || UserRole.USER,     permissions: user.permissions || getPermissionsForRole(user.role || UserRole.USER),   };    return jwt.sign(payload, JWT_SECRET, {     algorithm: "HS256",     expiresIn: JWT_EXPIRY,     audience: JWT_AUDIENCE,     issuer: JWT_ISSUER,   }); }  /**  * Verifies an incoming JWT and returns the authenticated user payload if valid.  */ export function verifyToken(token: string): AuthenticatedUser {   try {     const decoded = jwt.verify(token, JWT_SECRET, {       algorithms: ["HS256"],       audience: JWT_AUDIENCE,       issuer: JWT_ISSUER,     }) as jwt.JwtPayload;      // Ensure the decoded token has the fields we expect     if (typeof decoded.id !== "string" || typeof decoded.role !== "string") {       throw new Error("Token payload is missing required fields.");     }      return {       id: decoded.id,       role: decoded.role as UserRole,       permissions: decoded.permissions || [],     };   } catch (error) {     // Log the specific error for debugging, but return a generic message     console.error("JWT verification failed:", error.message);     if (error instanceof jwt.TokenExpiredError) {       throw new Error("Token has expired.");     }     if (error instanceof jwt.JsonWebTokenError) {       throw new Error("Invalid token.");     }     throw new Error("Could not verify token.");   } } 

\

Step 3: Building the Authentication Middleware

A "middleware" is a function that runs before your main request handler. It's the perfect place to put our security check. This middleware will inspect every incoming request, look for a JWT in the Authorization header, and verify it.

\ If the token is valid, it attaches the user's information to the request object for later use. If not, it immediately sends a 401 Unauthorized error and stops the request from proceeding further.

\ To make this type-safe, we'll also extend Express's Request interface to include our user object.

// src/server-middlewares.ts  import { Request, Response, NextFunction } from "express"; import { verifyToken, AuthenticatedUser } from "./auth/jwt.js";  // Extend the global Express Request interface to add our custom 'user' property declare global {   namespace Express {     interface Request {       user?: AuthenticatedUser;     }   } }  export function authenticateJWT(   req: Request,   res: Response,   next: NextFunction ): void {   const authHeader = req.headers.authorization;    if (!authHeader || !authHeader.startsWith("Bearer ")) {     res.status(401).json({       error: "Authentication required",       message: "Authorization header with 'Bearer' scheme must be provided.",     });     return;   }    const token = authHeader.substring(7); // Remove "Bearer "    try {     const userPayload = verifyToken(token);     req.user = userPayload; // Attach user payload to the request     next(); // Proceed to the next middleware or request handler   } catch (error) {     res.status(401).json({       error: "Invalid token",       message: error.message,     });   } } 

\

Step 4: Protecting the MCP Server

Now we have all the pieces. Let's put them together to protect our server.

\ First, we apply our authenticateJWT middleware to the main MCP endpoint in src/index.ts. This ensures every request to /mcp must have a valid JWT.

// src/index.ts // ... other imports import { authenticateJWT } from "./server-middlewares.js";  // ... const MCP_ENDPOINT = "/mcp"; const app = express();  // Apply security middleware ONLY to the MCP endpoint app.use(MCP_ENDPOINT, authenticateJWT); // ... rest of the file 

\ Next, we'll enforce our fine-grained permissions. Let's secure the ListTools handler in src/server.ts. We'll modify it to check if the authenticated user has the Permission.LIST_TOOLS permission before returning the list of tools.

// src/server.ts // ... other imports import { hasPermission, Permission } from "./auth/authorization.js";  // ... inside the StreamableHTTPServer class  private setupServerRequestHandlers() {   this.server.setRequestHandler(ListToolsRequestSchema, async (request) => {     // The user is attached to the request by our middleware     const user = this.currentUser;      // 1. Check for an authenticated user     if (!user) {       return this.createRPCErrorResponse("Authentication required.");     }      // 2. Check if the user has the specific permission to list tools     if (!hasPermission(user, Permission.LIST_TOOLS)) {       return this.createRPCErrorResponse(         "Insufficient permissions to list tools."       );     }      // 3. If checks pass, filter tools based on user's permissions     const allowedTools = TodoTools.filter((tool) => {       const requiredPermissions = this.getToolRequiredPermissions(tool.name);       // The user must have at least one of the permissions required for the tool       return requiredPermissions.some((p) => hasPermission(user, p));     });      return {       jsonrpc: "2.0",       tools: allowedTools,     };   });    // ... other request handlers } 

\ With this change, a user with a readonly role can list tools, but a user without the LIST_TOOLS permission would be denied access.

\

Conclusion and Next Steps

Congratulations! You've successfully implemented a robust authentication and authorization layer for your MCP server. By following these steps, you have:

  • Defined clear roles and permissions.
  • Created a centralized service for handling JWTs.
  • Built a middleware to protect all incoming requests.
  • Enforced granular permissions at the tool level.

Your MCP server is no longer an open door—it's a secure service. From here, you can expand on these concepts by adding more roles, more permissions, and even more complex business logic to your authorization system.

\ Star our GitHub repository to stay updated and receive notifications about future improvements.

Disclaimer: The articles reposted on this site are sourced from public platforms and are provided for informational purposes only. They do not necessarily reflect the views of MEXC. All rights remain with the original authors. If you believe any content infringes on third-party rights, please contact service@support.mexc.com for removal. MEXC makes no guarantees regarding the accuracy, completeness, or timeliness of the content and is not responsible for any actions taken based on the information provided. The content does not constitute financial, legal, or other professional advice, nor should it be considered a recommendation or endorsement by MEXC.

You May Also Like

Strive’s $500M Bitcoin ATM Program Could Boost Stock Value Up to 30x in 10 Years

Strive’s $500M Bitcoin ATM Program Could Boost Stock Value Up to 30x in 10 Years

The post Strive’s $500M Bitcoin ATM Program Could Boost Stock Value Up to 30x in 10 Years appeared on BitcoinEthereumNews.com. Strive’s $500M SATA ATM program enables the issuance of preferred stock to fund Bitcoin acquisitions, enhance financial flexibility, and support long-term growth. This strategic move, filed with the SEC on December 9, 2025, positions the company to hold more BTC while potentially boosting stock value through compounding effects over 20 years. Strive’s $500M SATA ATM targets Bitcoin purchases and corporate expansion to build lasting financial strength. Financial projections suggest the stock could multiply 30 times in 10 years due to Bitcoin’s growth and leverage strategies. With 7,525 BTC already held as of November 7, 2025, sustained demand for SATA could elevate stock prices to $1,160 by year 20, per analyst models. Discover how Strive’s $500M SATA ATM program fuels Bitcoin strategy and stock growth. Learn projections, goals, and impacts in this detailed analysis. Stay ahead in crypto finance—explore now! What is Strive’s $500M SATA ATM Program? Strive’s $500M SATA ATM program is an at-the-market offering designed to issue up to $500 million in Variable Rate Series A Perpetual Preferred Stock, known as SATA. This initiative, detailed in a sales agreement filed with the Securities and Exchange Commission on December 9, 2025, provides Strive with flexible capital-raising options without fixed timelines or pricing commitments. The proceeds will primarily support Bitcoin holdings, acquisitions, debt repayment, and other corporate needs, reinforcing the company’s commitment to digital assets. How Does the SATA ATM Structure Support Bitcoin Growth? The SATA ATM allows Strive to sell shares opportunistically through broker-dealers, adapting to market conditions for optimal pricing. This structure minimizes dilution risks while generating funds for strategic investments. As of November 7, 2025, Strive already holds 7,525 BTC, and additional acquisitions via this program could amplify exposure to Bitcoin’s potential appreciation. Financial analyst Adam Livingston highlights the program’s role in “long-term intelligent leverage on Bitcoin,” enabling…
Share
BitcoinEthereumNews2025/12/10 23:15
Cryptos Signal Divergence Ahead of Fed Rate Decision

Cryptos Signal Divergence Ahead of Fed Rate Decision

The post Cryptos Signal Divergence Ahead of Fed Rate Decision appeared on BitcoinEthereumNews.com. Crypto assets send conflicting signals ahead of the Federal Reserve’s September rate decision. On-chain data reveals a clear decrease in Bitcoin and Ethereum flowing into centralized exchanges, but a sharp increase in altcoin inflows. The findings come from a Tuesday report by CryptoQuant, an on-chain data platform. The firm’s data shows a stark divergence in coin volume, which has been observed in movements onto centralized exchanges over the past few weeks. Bitcoin and Ethereum Inflows Drop to Multi-Month Lows Sponsored Sponsored Bitcoin has seen a dramatic drop in exchange inflows, with the 7-day moving average plummeting to 25,000 BTC, its lowest level in over a year. The average deposit per transaction has fallen to 0.57 BTC as of September. This suggests that smaller retail investors, rather than large-scale whales, are responsible for the recent cash-outs. Ethereum is showing a similar trend, with its daily exchange inflows decreasing to a two-month low. CryptoQuant reported that the 7-day moving average for ETH deposits on exchanges is around 783,000 ETH, the lowest in two months. Other Altcoins See Renewed Selling Pressure In contrast, other altcoin deposit activity on exchanges has surged. The number of altcoin deposit transactions on centralized exchanges was quite steady in May and June of this year, maintaining a 7-day moving average of about 20,000 to 30,000. Recently, however, that figure has jumped to 55,000 transactions. Altcoins: Exchange Inflow Transaction Count. Source: CryptoQuant CryptoQuant projects that altcoins, given their increased inflow activity, could face relatively higher selling pressure compared to BTC and ETH. Meanwhile, the balance of stablecoins on exchanges—a key indicator of potential buying pressure—has increased significantly. The report notes that the exchange USDT balance, around $273 million in April, grew to $379 million by August 31, marking a new yearly high. CryptoQuant interprets this surge as a reflection of…
Share
BitcoinEthereumNews2025/09/18 01:01