Your MCP Server Doesn't Need Auth Code. It Needs a Gateway.
The pattern is old. The problem is new.
nginx sits in front of your web server. It terminates TLS, checks headers, enforces rate limits. Your app never sees the raw connection.
API gateways sit in front of your microservices. They validate JWTs, route traffic, collect metrics. Your service code stays clean.
MCP servers have no equivalent. Auth happens inside the tool handler, or it doesn't happen at all.
What that looks like today
You build an MCP server. It exposes tools. An agent calls those tools over stdio or HTTP.
If you want auth, you have two options:
- Import a middleware library and wire it into your server code
- Skip auth and hope the transport layer is enough
Option 1 means your server needs to understand proof bundles, permission bitmasks, nonce stores, and receipt signing. Option 2 means any agent that can reach the transport can call any tool.
Neither is what you want.
Auth in front, not inside
What you want is a proxy that intercepts requests before they reach your server. The proxy handles verification. Your server handles tools.
verifyBundle() · checkToolPolicy() · rejectReplay() · emitReceipt()
The gateway verifies agent credentials using ZKP proof bundles. It checks per-tool permission policies. It rejects replayed nonces. It emits a signed audit receipt for every decision, allow and deny. Then it forwards the request with X-Bolyra-* headers that tell your server who passed verification.
Your server reads headers. It never imports a crypto library.
One command
npx @bolyra/gateway --target http://localhost:3000/mcp
That starts a reverse proxy on port 4000. Every tools/call request gets verified before it reaches your server. Everything else (initialize, ping) passes through.
Add a config file for per-tool policies:
# gateway.yaml
target: http://localhost:3000/mcp
toolPolicy:
read_file: 1 # READ_DATA
write_file: 2 # WRITE_DATA
delete_file: 2 # WRITE_DATA
transfer: 16 # FINANCIAL_UNLIMITED
receipts:
file: ./receipts/
hmac:
secret: ${GATEWAY_HMAC_SECRET}
Now read_file requires a credential with at least READ_DATA permission. transfer requires FINANCIAL_UNLIMITED. Every verification decision writes a signed receipt to ./receipts/. The HMAC secret signs X-Bolyra-* headers so your upstream can verify they came from the gateway, not a forged client.
What the gateway decides
For every tools/call request:
- Credential verification. The proof bundle is verified via
verifyBundle(). Agent identity, delegation chain, scope commitment, expiry. If any check fails: 401. - Tool policy. The agent's effective permission bitmask is checked against the tool's required permission. If the agent lacks the bits: 403.
- Replay protection. The session nonce is checked against the nonce store. If it's been seen before: 401.
- Receipt. A signed receipt records the decision. Allow or deny. Timestamped, signed with ES256K.
For non-tool methods (initialize, notifications/initialized, ping): pass through without auth. These are handshake and keepalive, not actions.
For everything else (resources/read, prompts/get): auth is required but no tool policy is checked. You get identity verification without tool-level gating.
What your server sees
After the gateway verifies a request, it injects headers:
X-Bolyra-Verified: true
X-Bolyra-DID: did:bolyra:base-sepolia:0x1a2b...
X-Bolyra-Permissions: 3
X-Bolyra-Score: 85
X-Bolyra-HMAC: sha256=a1b2c3...
Your server reads X-Bolyra-Verified and X-Bolyra-Permissions. No SDK import. No proof parsing. No nonce tracking. Just headers.
The HMAC proves the headers came from the gateway. Without it, a client that bypasses the proxy could forge X-Bolyra-Verified: true. With it, your server can verify the signature in one line.
Why not just use middleware?
Middleware works. @bolyra/mcp ships withBolyraAuthStdio() and it's the right choice if you want auth wired directly into your server process.
The gateway is for a different situation:
- You didn't write the MCP server. You're running someone else's server and want to add auth without forking it.
- You have multiple MCP servers. One gateway, one policy file, N servers behind it.
- You want separation of concerns. Auth decisions in one process, tool logic in another. Different deploy cadence, different failure domain.
- You want audit receipts without touching server code. The gateway emits receipts for every decision. Your server never calls a receipt API.
Middleware and gateway are not competing. The gateway uses the same verifyBundle() and checkToolPolicy() from @bolyra/mcp. Same verification, different deployment topology.
For library users
If you do want the gateway logic inside your process, import the middleware:
import { createGatewayMiddleware } from '@bolyra/gateway';
const middleware = createGatewayMiddleware({
toolPolicy: { read_file: 1n, write_file: 2n },
receipts: { stdout: true },
});
app.use('/mcp', middleware, mcpHandler);
Same verification pipeline. Express, Hono, Fastify, or any framework that takes (req, res, next).
The shape of the thing
Last time we asked: who is this agent, did a human authorize it, what tools can it use, has this proof been seen before, can you prove what happened?
The gateway answers all five without touching your server code.
Agent identity: verified via the proof bundle's credential commitment. Human delegation: checked through the delegation chain. Tool permissions: enforced by the tool policy map. Replay: blocked by the nonce store. Audit: signed receipts on every decision.
Your server stays clean. The gateway handles the rest.
npx @bolyra/gateway --target http://localhost:3000/mcp
← Previous: MCP Auth Works in Dev. Then Production Asks: Who Is This User?
See the gateway source, config reference, and architecture docs.
View on GitHub