Safeguard
AI Security

Writing an MCP Server That Holds No Credentials

Give a model a tool that writes and you have added a route into whatever sits behind it. The design that keeps it a new shape rather than a new privilege, and the four things that were not obvious.

Shadab Khan
Engineering
7 min read

The moment you give a model a tool that writes something, you have added a route into whatever sits behind that tool. The only question worth answering is whether it is a new privilege or only a new shape. If the server holds a credential of its own, it is a new privilege, and you now own an authorization system you did not plan to build. If it forwards the caller's credential and holds nothing, it can do exactly what the caller could already do by hand.

We built a small MCP server so an agent could draft blog posts instead of a person pasting into a studio. It has one tool. This is the design and the four things that were not obvious, written for anyone about to put a write tool in front of a model.

The rule the whole thing hangs on

The server holds no credential. No service token, no API key, no shared secret in the environment.

Every request must carry the caller's own session token in Authorization, and that token is forwarded unchanged to the API behind it, which enforces sessions exactly as it does for the browser. So the endpoint grants nothing. If someone reaches it without a valid token, the API refuses and the server returns that refusal verbatim.

This sounds obvious written down. It is not what most internal tool servers do, because holding a service account is easier: you do not have to plumb the caller's identity through, and the tool "just works" in a script. The cost is that the server becomes a confused deputy, a thing with more authority than any of its callers, reachable by anything that can reach the port. Every subsequent security decision on that server exists only to compensate.

Hold no credential and most of those decisions disappear.

Bearer only, never the cookie

The admin panel authenticates browsers with a cookie. The MCP endpoint lives on the same origin. It would be one line to accept the cookie as a fallback, and it would be very convenient, because then the tool works in a browser session with no setup.

It would also make every tool call reachable from any page a logged in administrator visits. That is CSRF, in its most classic form: the browser attaches the cookie automatically, the attacker's page supplies the body. A JSON content type triggers a preflight and is blocked, but a cross origin POST with Content-Type: text/plain is a simple request that goes through without one, and a JSON-RPC handler that parses the body regardless of content type will happily accept it.

So: read Authorization, and nothing else. A token has to be deliberately attached by a client. A cookie attaches itself.

function bearerToken(request: NextRequest): string | null {
  const header = request.headers.get('authorization') ?? '';
  const match = /^Bearer\s+(.+)$/i.exec(header.trim());
  return match ? match[1].trim() : null;
}

Check Origin when it is present

The MCP specification calls out DNS rebinding for local servers, and the reasoning applies to any server a browser can reach. A page loaded from an attacker's domain resolves a hostname to your address and then talks to your server with the browser as its proxy.

A non-browser client sends no Origin header and is unaffected. A browser always sends one. So the check costs nothing and closes the case:

const ALLOWED_ORIGINS = new Set([
  'https://admin.example.com',
  'http://localhost:3003',
]);
// absent Origin: fine, not a browser. Present and unknown: 403.

"Create" over an upsert is not create

This is the one that would have bitten us in production, and it has nothing to do with authentication.

The content API behind the tool is an upsert keyed on slug. PUT with a slug that exists overwrites the row. That is correct behaviour for the studio, where a human opened the post they intended to edit.

It is wrong behaviour under a tool called create_blog_post, because a model that runs twice on a similar prompt derives the same slug twice, and the second run silently replaces a published post with a fresh draft. Nobody gets an error. The post just changes.

So the tool reads the slug first and refuses when it is taken:

const existing = await fetch(`${API}/type/blog/${encodeURIComponent(slug)}`, { headers });
if (existing.ok) {
  return { text: `A post already exists at "${slug}". This tool only creates.`, isError: true };
}
if (existing.status !== 404) {
  return { text: `Could not check whether "${slug}" is free (API answered ${existing.status}).`, isError: true };
}

Two details. The !== 404 branch matters: if the check itself fails, refuse rather than proceed, or a transient error becomes an overwrite. And be honest about what this is. It is a check-then-act, so two concurrent calls on the same slug can still race. It narrows the window, it does not close it. Closing it needs a conditional write on the API, which is the right fix and is not yet there.

The general form of the lesson: when you wrap an existing endpoint in a tool, the tool's name is a promise about semantics the endpoint may not have. Read the endpoint's actual contract, not its name.

Pin the arguments the caller should not choose

The API writes many content types. The site's navigation, footer and headline metrics are rows in the same table, read by the layout on every page.

If the tool took type from its arguments, a model could be talked into writing one of those rows, and the blast radius of a bad tool call goes from one draft post to the site's chrome. So the type is set in the route, as a constant, not read from the input schema:

const payload = {
  type: 'BLOG',           // fixed here, never taken from the caller
  status: args.publish === true ? 'PUBLISHED' : 'DRAFT',
  // ...
};

Anything that determines what kind of thing gets written, or where it gets written, belongs on the server. The model chooses content. It does not choose targets.

Draft by default

publish defaults to false. The normal result of an automated run is something a person reviews.

This is the cheapest control in the whole design and it does more than the rest of them combined, because it converts every other failure mode from "live" into "queued". It also survives prompt injection in a way the authentication controls do not: if a model is steered into writing something it should not, the output still lands in a review queue.

What this does not protect against

Worth saying plainly.

It does not protect against a stolen admin token. Nothing here does. The design bounds the damage to what that administrator could already do, which is the honest goal, not zero.

It does not stop prompt injection. A model reading a poisoned document can be steered into calling the tool. The mitigations are the two above: the type is pinned, so the target is fixed, and the default is a draft, so a human sees it. The MCP layer is not where you solve injection.

It does not audit. The forwarded token identifies the caller to the API, so the API's own logging carries the identity, which is the reason to forward rather than substitute. But that is the API's audit trail, not the tool's.

The implication

Almost all of this reduces to one decision made at the start: the server holds no credential of its own. Everything else is a consequence. The CSRF rule exists because ambient authority is the thing to avoid. The pinned type exists because the tool should not be able to express requests the caller would not make. The draft default exists because review is cheaper than rollback.

If you are adding a write tool to an MCP server, start there. Ask what the server can do that its caller cannot. If the answer is anything, you have built a privilege boundary, and you are now responsible for defending it.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.