TellWorldAI World Archive
Ask中文

Home›Practice›Practice

Practice

Adding authentication to an MCP server

Start MCP server authentication with a credential in the Authorization header, move to signed JWTs, then add role-based permission checks in middleware. Each step lists its limits.

Who this is for and what you will be able to do

If your MCP server can be called by anyone who can reach it, you need to decide who may connect and what each person may do. This guide walks through three steps: a simple credential in the Authorization header, a signed JWT in its place, and role-based permission checks in middleware. By the end you will know what each step protects against, where it falls short, and what to read next [1].

Authentication and authorization are different checks

Authentication decides whether a person may reach the resource server where the MCP server's features live. Authorization decides whether that person may use a specific resource, for example reading content but not deleting it [1]. MCP SDKs support OAuth 2.1, which involves an auth server, a resource server and a bearer token, and recommends starting with a basic level of auth before moving to stronger security [1].

Step 1: send a credential in the Authorization header

A basic credential is a base64-encoded username and password, or an API key, sent in the Authorization header [1]. Middleware is code that runs as part of each request. It checks the credential and lets the request pass if the credential is valid. Otherwise the client receives an auth error [1].

In Python, the middleware returns 401 Unauthorized when the header is missing and 403 Forbidden when the token is invalid [1]:

class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):

        has_header = request.headers.get("Authorization")
        if not has_header:
            print("-> Missing Authorization header!")
            return Response(status_code=401, content="Unauthorized")

        if not valid_token(has_header):
            print("-> Invalid token!")
            return Response(status_code=403, content="Forbidden")

        print("Valid token, proceeding...")
        print(f"-> Received {request.method} {request.url}")
        response = await call_next(request)
        response.headers['Custom'] = 'Example'
        return response

The valid_token function in the example is marked as not for production:

# DON'T use for production - improve it !!
def valid_token(token: str) -> bool:
    # remove the "Bearer " prefix
    if token.startswith("Bearer "):
        token = token[7:]
        return token == "secret-token"
    return False

Secrets should never sit in code. The value to compare against should come from a data source or an identity provider, or the identity provider should do the validation itself [1].

In TypeScript, Express middleware does the same job. It sends 401 when the header is missing, sends 403 when the token fails the isValid check, and calls next() otherwise [1]:

app.use((req, res, next) => {
    console.log('Request received:', req.method, req.url, req.headers);
    console.log('Headers:', req.headers["authorization"]);
    if(!req.headers["authorization"]) {
        res.status(401).send('Unauthorized');
        return;
    }
    
    let token = req.headers["authorization"];

    if(!isValid(token)) {
        res.status(403).send('Forbidden');
        return;
    }  

    console.log('Middleware executed');
    next();
});

On the client side, the Python example passes the credential in a header. Hardcoding it is not recommended [1]:

# DON'T hardcode the value, have it at minimum in an environment variable or a more secure storage
token = "secret-token"

async with streamablehttp_client(
        url = f"http://localhost:{port}/mcp",
        headers = {"Authorization": f"Bearer {token}"}

The TypeScript client puts the header under requestInit in an options object that is passed to the transport [1]:

// DON'T hardcode the value like shown here. At minimum have it as a env variable and use something like dotenv (in dev mode).
let token = "secret123"

// define a client transport option object
let options: StreamableHTTPClientTransportOptions = {
  sessionId: sessionId,
  requestInit: {
    headers: {
      "Authorization": "secret123"
    }
  }
};

This is a good start for very simple APIs. It is risky for anything more, because the credential can be stolen even over HTTPS. You then need a way to revoke tokens and extra checks, such as where a request comes from and how often it happens [1].

Step 2: replace the secret with a JWT

A JWT has three parts: a header naming the algorithm and token type, a payload of claims such as sub, exp and role, and a signature made with a secret or private key [1]. The gains are: tokens expire, roles, scopes and permissions allow fine-grained control, tokens carry their own information so the server needs no session storage, and they can be validated locally [1].

The Python example builds a header and a payload, then encodes the token with jwt.encode [1]:

secret_key = 'your-secret-key'

header = {
    "alg": "HS256",
    "typ": "JWT"
}

# the user info and its claims and expiry time
payload = {
    "sub": "1234567890",               # Subject (user ID)
    "name": "User Userson",                # Custom claim
    "admin": True,                     # Custom claim
    "iat": datetime.datetime.utcnow(),# Issued at
    "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1)  # Expiry
}

# encode it
encoded_jwt = jwt.encode(payload, secret_key, algorithm="HS256", headers=header)

In the TypeScript example, install the libraries first [1]:

npm install jsonwebtoken
npm install --save-dev @types/jsonwebtoken

The token is then created with jwt.sign. The example token is signed with HS256 and valid for one hour [1]:

const token = jwt.sign(payload, secretKey, {
  algorithm: 'HS256',
  header: header
});

On the server, the token has to be validated. In Python, jwt.decode(token, secret_key, algorithms=["HS256"]) does this, and the example catches ExpiredSignatureError and InvalidTokenError [1]. In TypeScript, jwt.verify fails if the token is malformed or no longer valid [1]:

try {
  const decoded = jwt.verify(token, secretKey);
  console.log('Decoded Payload:', decoded);
} catch (err) {
  console.error('Token verification failed:', err);
}

Validation should also check that the token points to a user in your system and that the user has the rights it claims [1].

Step 3: role-based access control

The RBAC example uses three permission levels, Admin.Write, User.Read and Guest.Read. An admin can do everything, a normal user can read and write, and a guest can only read [1]. The Python middleware below checks the decoded token's permissions list for REQUIRED_PERMISSION, which is set to User.Read, and returns 403 with Permission denied if it is missing [1]:

class JWTPermissionMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        auth_header = request.headers.get("Authorization")
        if not auth_header or not auth_header.startswith("Bearer "):
            return JSONResponse({"error": "Missing or invalid Authorization header"}, status_code=401)

        token = auth_header.split(" ")[1]
        try:
            decoded = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
        except jwt.ExpiredSignatureError:
            return JSONResponse({"error": "Token expired"}, status_code=401)
        except jwt.InvalidTokenError:
            return JSONResponse({"error": "Invalid token"}, status_code=401)

        permissions = decoded.get("permissions", [])
        if REQUIRED_PERMISSION not in permissions:
            return JSONResponse({"error": "Permission denied"}, status_code=403)

        request.state.user = decoded
        return await call_next(request)

Adding permission checks to MCP

Middleware alone gives no easy way to check permissions per MCP feature. It suggests checking the permission inside each tool, resource or prompt, or using the request handlers of the advanced server approach so that the check is in fewer places [1]. The Python has_permission helper returns true when the user has at least one required permission. Its example maps create_product to User.Write or Admin.Write, and delete_product to Admin.Write [1].

Limits and common questions

  • The examples use placeholder secrets such as secret-token and your-secret-key. These must come from a safe place, such as an environment variable [1].
  • Helper libraries exist for Express. express-jwt decodes the token and attaches it to req.user, and express-jwt-permissions provides a guard middleware that checks for a permission [1].

Next step

The next topic is setting up MCP hosts [1].

Adapted from the simple-auth lesson of Microsoft's open-source course *mcp-for-beginners*[1], released under the MIT License.

Sources

  1. 1
    Simple authMicrosoft mcp-for-beginners (MIT)

Cite this page: Adding authentication to an MCP server. TellWorld, https://www.tellworld.net/practice/practice-mcp-simple-auth. Updated 2026-11-14

Related news

View all