Background
MCP (Model Context Protocol) is Anthropic's open standard for connecting LLM agents to external tools. A few of these tools include databases, file systems, APIs and shell access. MCP is only 18 months old, and the ecosystem is growing fast. This means very few of those servers have been looked at by anyone with a security background.
I've been auditing community MCP servers as part of research at Shroud Labs. This post covers the most interesting finding so far: a CVSS 9.8 Critical authentication bypass in a server explicitly advertising OAuth 2.1 protection.
The target
A TypeScript MCP server implementing the MCP spec's OAuth 2.1 authorization extension. Its entire purpose is to be an example of how to build a secure remote MCP server with proper bearer token authentication. It ships in two variants: a stateful version (maintains session state) and a stateless version (no session state, suitable for serverless deployments).
The README describes it as production-ready. It's installable and configured against a real Auth0 tenant.
The hunt
I built a specialised TLAD scanner (Transport-Layer Auth Divergence): a Semgrep ruleset I built to detect a specific pattern. This detects if auth middleware is applied to one transport variant but missing on another. I ran the scanner across 49 community HTTP MCP servers.
For this repo, it flagged both an authed mount and an unguarded mount in the same codebase. I manually checked for differences in the two files for the Layer 2 review. Took me 2 minutes.
app.stateful.ts line 150:
typescript
app.post("/mcp", bearerAuthMiddleware, async (req, res, next) => {
app.stateless.ts line 107:
typescript
app.post("/mcp", async (req: Request, res: Response, next: NextFunction) => {
One argument. bearerAuthMiddleware is defined a few lines earlier in both files. It's used correctly in the stateful version. On the stateless version's /mcp route, it's completely missing.
The middleware was created. It was wired in everywhere else. It was just never passed to the one route that matters.
Why this is actually critical
bearerAuthMiddleware is what calls requireBearerAuth() — the function that validates the OAuth token. Without it in the middleware chain, the route handler runs regardless of what the caller sends.
That means:
No Authorization header → 200 OK
Fake bearer token → 200 OK
Revoked token → 200 OK
Someone else's token → 200 OK
The OAuth flow, the Auth0 tenant, the token issuance, the scope checking. It's all rendered irrelevant. The auth check never fires, and every tool exposed by the server is invocable by anyone who can reach the HTTP endpoint.
Dynamic confirmation
To be certain, I stood up the server locally with dummy OAuth credentials and ran two requests:
bash
Request 1: no token at all
curl -s -X POST http://localhost:5050/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
HTTP 200 — full tool list returned
Request 2: fake bearer token
curl -s -X POST http://localhost:5050/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer notarealtoken" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
HTTP 200 — full tool list returned again
The server logs confirmed both requests hit the route handler. Neither was rejected.
For comparison, the stateful version correctly returns 401 Unauthorized on both requests. Same codebase, different file, one missing argument.
CVSS 9.8
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
AV:N — the server binds to a network port for HTTP access. That's its whole purpose.
AC:L — no special conditions. Just send a request.
PR:N — no credentials required. Obviously.
UI:N — no user interaction.
C:H / I:H / A:H — complete access to all tools. Depending on what tools are configured, that's data access, writes, or arbitrary actions.
9.8 is the right score. The server is explicitly designed for network deployment.
The fix
One line, in app.stateless.ts:
typescript
// Before
app.post("/mcp", async (req, res, next) => {
// After
app.post("/mcp", bearerAuthMiddleware, async (req, res, next) => {
Identical to what the stateful version already does correctly. The middleware is already defined and imported. It just needs to be passed to this route.
The lesson
The bug lived in a diff between two files that were supposed to be equivalent in their security properties.
Security bugs aren't always exotic. Sometimes the control exists, is tested elsewhere, is working correctly on the other variant — and was simply never wired to the specific code path that runs in production.
When you're auditing a server that has multiple transport modes, multiple deployment variants, or multiple files implementing similar functionality: compare them. The gap between them is where the bug lives.
Rule: when two files should be identical in their security properties, diff them. The gap is the finding.
CVE and tooling
This was disclosed as CVE-2026-2035999 (CVSS 9.8 Critical).
The TLAD scanner that surfaced it is open source:
A companion general-purpose sink ruleset for MCP servers (mcp-sinks) covers command injection, path traversal, SSRF, SQLi, and deserialisation across Python and TypeScript.
ShroudLabs-io
/
mcp-sinks
Semgrep sink ruleset for MCP server security research — command injection, path traversal, SSRF, SQLi, deserialization, transport hygiene
mcp-sinks — Semgrep ruleset for MCP server vulnerability research
A two-tier Semgrep ruleset for auditing Model Context Protocol (MCP) servers for security vulnerabilities. Used to discover CVE-2026-2035922 (mac-shell-mcp) and a newline-separator allow-list bypass in cmd-line-mcp.
What it finds
Inventory rules (WARNING) — flags every dangerous sink so you can trace whether a tool argument reaches it. High recall, expect false positives.
Taint rules (ERROR) — MCP-aware taint analysis tracing tool handler arguments directly to dangerous sinks. Higher precision.
Audit rules — narrow, high-confidence patterns for specific known
bypass shapes (a validator regex that omits \n, an execFile/spawn
call whose shell option isn't a hard false), rather than a generic
sink flag.
Covers six vulnerability classes across Python and JavaScript/TypeScript:
- OS command injection (CWE-78)
- Path traversal / sandbox escape (CWE-22)
- Server-side request forgery (CWE-918)
- SQL injection (CWE-89)
- Insecure deserialisation (CWE-502)
- Transport hygiene — 0.0.0.0 binding, TLS verification…
I surveyed 49 community HTTP MCP servers with these tools. Of the published packages with HTTP exposure in the sample, 100% had no inbound authentication. The ecosystem is young and largely unaudited. The tooling is now open.
Top comments (0)