Building a self-hosted MCP server: the auth checklist nobody publishes

Every blog post tells you how to use an MCP server. This is the post about how to build one that does not show up on a security report. Stdio and HTTP, OAuth and env-var, with a real checklist.

May 26, 202612 min read

Building a self-hosted MCP server: the auth checklist nobody publishes.

A January 2026 scan of 1,808 MCP servers found that 66% had at least one security finding, with 13% being outright authentication bypasses. A broader survey of 17,000 servers in February found that 41% had no authentication at all. The mcp-remote package (437K downloads) shipped a CVSS 9.6 command injection through its OAuth flow. Microsoft patched CVE-2026-26118, an 8.8 SSRF in Azure MCP Server Tools, in March.

The point is not that MCP servers are uniquely insecure. It's that the rush to ship one has outpaced the work of doing it safely. Every blog post tells you how to use a server. None of them walk you through how to build one that won't show up on the next quarterly scan.

This is that checklist. Decisions first, code second.

First decision: stdio or HTTP?

Everything downstream depends on this. The MCP spec (2025-11-25 draft) splits sharply:

  • STDIO transport SHOULD NOT follow the authorization spec. Credentials come from the environment.
  • HTTP transport SHOULD conform to the OAuth 2.1 model with PKCE, Resource Indicators, and Protected Resource Metadata.

If your server runs as a subprocess of one user's IDE on one user's machine and never reaches the network for anything privileged, stdio is fine. If your server runs anywhere remote, anywhere multi-user, or holds credentials more interesting than the OS user's existing access, HTTP with OAuth is the answer.

Most teams trying to ship something quickly default to stdio because "that's what the examples use." Most teams trying to ship something maintainable end up at HTTP. The middle path (stdio with env-var-pasted credentials that get committed to a config file) is the path that produces the 24,008 secrets GitGuardian found leaked in MCP configs.

The stdio path: what's safe and what's not

A stdio MCP server inherits the launching user's environment. The trust model is "the OS user is the boundary." Three things have to be true for this to be safe:

  1. The server holds no third-party credentials at all (the seven official reference servers are like this), or
  2. The credentials it holds are bounded in blast radius to what the OS user already has (e.g., a Git server that uses the user's existing ~/.ssh/ key), or
  3. The credentials are injected from outside the config file at run time.

Most community stdio servers fail all three. They use third-party credentials (GITHUB_PERSONAL_ACCESS_TOKEN, STRIPE_SECRET_KEY), they have org-wide blast radius (a PAT at repo scope reads every private repo), and they expect those credentials to land in ~/.claude.json or ~/.cursor/mcp.json. That file then ends up in someone's dotfiles repo. The leak path is git push plus cat.

If you must ship a stdio server with third-party credentials, here's the less-bad pattern:

json
{
  "mcpServers": {
    "github": {
      "command": "authsome",
      "args": ["run", "--", "npx", "-y", "@modelcontextprotocol/server-github"]
    }
  }
}

The credential broker (authsome here, but any agent-side broker works) holds the GitHub token in an encrypted vault. The server gets a placeholder env var; the real token is injected at the outbound HTTPS boundary. The config file you commit has no secrets. Disclosure: I work on authsome; the broader category is covered in the credential brokers comparison.

If you don't want to add a broker dependency, the next-best stdio pattern is: read credentials from a separate file that's ~/.config/myserver/credentials (mode 0600, gitignored everywhere), not from the host config. Document this in your README in the first paragraph, not in a "security considerations" section nobody reads.

The HTTP path: OAuth, the short version

For a remote (HTTP) MCP server, the spec is precise. You need:

  1. Protected Resource Metadata at /.well-known/oauth-protected-resource (RFC 9728), pointing at an authorization server.
  2. OAuth 2.1 (draft-13) with PKCE S256.
  3. Resource Indicators (RFC 8707). Every token request MUST include the resource parameter. Every token you issue MUST be audience-restricted to your server. Tokens missing the audience claim MUST be rejected.
  4. WWW-Authenticate header on every 401 with resource_metadata="...".
  5. CIMD (draft-ietf-oauth-client-id-metadata-document-00) SHOULD be supported for unknown clients. DCR (RFC 7591) MAY be supported as fallback.

The bypass path most teams take: use a framework that handles all of this. Cloudflare's workers-oauth-provider is the canonical example. It wraps your Worker code, adds authorization to your API endpoints, and your handler receives already-authenticated user details as a parameter. You write your tool logic; the library handles the spec compliance.

For non-Cloudflare deployments, the WorkOS AuthKit, Auth0 "Auth for MCP", and Stytch MCP libraries all play this role. Pick by which fits your stack; the spec compliance you get is roughly equivalent.

The full checklist

Sectioned so you can copy-paste into an internal RFC and edit.

Pre-work

  • Decide stdio or HTTP. Document the reason.
  • If HTTP, decide what your authorization server is. Self-hosted (Hydra, Keycloak), vendor (WorkOS, Auth0, Stytch), or cloud-platform (Cloudflare Access, AWS Cognito).
  • Skim the current MCP authorization spec. It's short. Read the MUST sections in full.
  • Read the Cloudflare reference implementation even if you're not deploying on Cloudflare. The patterns generalize.

Schema decisions (the actual hard part)

  • Map your existing permission model onto a scope vocabulary. 5-10 scopes total is the sweet spot. More than 15 is a sign you're inventing scopes that don't match real user-facing permissions.
  • Decide your audience identifier. The canonical URL of your MCP server (e.g., https://mcp.yourservice.com) is correct in 95% of cases.
  • Decide which scopes any client can request and which require a higher level of trust (org admin approval, paid tier, etc.).
  • Decide your token lifetime. Notion uses 1 hour for access tokens with refresh-token rotation; that's the right default unless you have a specific reason otherwise.

Infrastructure

  • Implement /.well-known/oauth-protected-resource returning the resource URL, authorization server URL, supported scopes, and bearer methods.
  • Ensure your auth server exposes either /.well-known/oauth-authorization-server (RFC 8414) or /.well-known/openid-configuration (OIDC Discovery).
  • Every 401 from your API includes WWW-Authenticate: Bearer resource_metadata="https://your.server/.well-known/oauth-protected-resource".
  • If you support CIMD, advertise client_id_metadata_document_supported: true in your AS metadata.

Token validation (where the security bugs are)

  • Validate token signature against the authorization server's JWKS. Cache the JWKS with a sane TTL (1 hour) and a forced-refetch path for key rotation events.
  • Validate the aud claim matches your audience identifier. This is the Resource Indicator check. The MCP spec is explicit: tokens missing the audience claim MUST be rejected. The 13% authentication-bypass rate in the January 2026 scan was largely missing or wrong audience validation.
  • Validate exp, iat, and (if present) nbf.
  • If you accept jti for replay protection, store seen jti's for the token lifetime.
  • Reject any token whose issuer doesn't match what you'd expect from your AS metadata.

Downstream auth (the part the spec doesn't cover)

This is where MCP servers leak credentials independently of how the MCP protocol auth is implemented.

  • How does your server authenticate to the underlying API (GitHub, Stripe, your DB)? If the answer is "a hardcoded PAT in env vars," you have a long-lived org-wide credential in the same process that's parsing untrusted tool arguments.
  • Prefer per-user OAuth tokens forwarded from the MCP client where the protocol supports it.
  • If you must hold service credentials, scope them to the minimum (read-only where possible, rotated on a schedule, with audit on every use).
  • Consider an outbound credential broker on the host running the server, so even the server process doesn't see the raw secret. This is what the agent-side broker pattern addresses.

Tool implementation

This is the part where the non-auth vulnerabilities live, and the data is brutal: 82% of MCP servers use file operations prone to path traversal, 67% have APIs vulnerable to code injection, 34% are susceptible to command injection, 36.7% have SSRF exposure.

  • Path traversal: every file path argument validated against an allowlist of roots. No .. traversal, no symlink-following without explicit opt-in.
  • Command injection: never pass tool arguments directly to a shell. Use the language's argv-style subprocess APIs. Validate.
  • SSRF: if you accept URLs as tool arguments, validate them against an allowlist or block private IP ranges (RFC 1918, loopback, link-local, cloud metadata endpoints like 169.254.169.254).
  • SQL injection: parameterized queries. Same as it's been for 25 years.
  • Output sanitization: if a tool's output gets fed back to an LLM that may then issue further tool calls, treat the output as untrusted input for the next call.

Audit

  • Log every tool invocation with: user, scopes, tool name, redacted arguments, timestamp, outcome.
  • Log every authentication event: token issued, token rejected (with reason), refresh, revoke.
  • Log to somewhere that survives the server crash. Append-only file on a different volume, or a managed log destination.
  • Have a way to query "what did this user's agent do in the last hour" without grepping log files. This is the difference between an incident you can investigate and one you can't.

Testing

  • Unit tests for token validation. Specifically: token with wrong audience rejected, token with no audience rejected, expired token rejected, token with valid signature but unknown issuer rejected.
  • Use MCP Inspector (web-based, runs against a local server) for manual flow testing.
  • Integration tests against a real OAuth flow in CI. The flows that aren't tested are the flows that break in production.
  • Fuzz test tool arguments with malicious paths, command injections, and oversized inputs.

Deployment

  • HTTPS only. No HTTP. No exceptions, even for "internal" servers.
  • Rate limiting on the /register endpoint if you support DCR.
  • Rate limiting on the token endpoint.
  • CORS configured tight. The MCP client is calling from a known host; the policy should reflect that.
  • Secrets in your deployment environment from a secrets manager, not from a .env file checked into the repo.

Common failures from the production data

The vulnerability classes in the 2026 scans aren't novel. They're the same web-app failure modes the security industry has been writing about for two decades, applied to a new transport.

Missing audience validation. The January 2026 scan's 13% auth-bypass figure was dominated by servers that accept any bearer token signed by a trusted issuer, regardless of which resource the token was minted for. A GitHub MCP server accepts a token meant for a Linear MCP server. The fix is one line of code (if token.aud != my_audience: reject) and most servers ship without it.

SSRF in URL-accepting tools. A "Fetch" tool that follows redirects to http://169.254.169.254/latest/meta-data/iam/security-credentials/ returns AWS credentials. 36.7% of scanned servers were exposed. The fix is an allowlist plus a private-IP-range block.

Command injection in shell-out tools. Any tool that takes a string argument and uses it in a shell command. The mcp-remote CVSS 9.6 was an OAuth flow that interpolated a parameter into a shell command without escaping.

Credentials in error messages. A tool fails. The error string includes the user's token. The error string goes into the audit log, the agent's stack trace, the LLM's context. Once the token is in a log file, it's in everyone's grep.

Trust-on-first-use registration. DCR endpoints that accept arbitrary client registrations without rate limiting. Attackers register thousands of clients, then use them for credential stuffing or just to bloat your database. The fix is rate limiting plus, ideally, moving to CIMD where there's no public write endpoint to abuse.

What you can defer

Not every checklist item ships in week one. The honest minimum viable secure MCP server:

  1. HTTPS.
  2. OAuth flow, even if it's a hardcoded shared OAuth app you reuse across all users.
  3. Audience validation on every token.
  4. Argument validation on every tool.

Everything else can wait until v0.2 if you write down what you deferred and why. The mistakes that bit teams in 2026 weren't "we shipped without per-user audit logs." They were "we shipped without audience validation" and "we shipped a Fetch tool that followed redirects to 169.254.169.254."

A worked example: TaskCo

The same hypothetical service I used in the auth.md post. TaskCo is a notes API; we're shipping an MCP server for it.

Decisions:

  • HTTP transport (it's a SaaS, not a local tool).
  • Auth server: WorkOS AuthKit (chosen because it has live CIMD support).
  • Scopes: tasks.read, tasks.write, projects.read.
  • Pre-claim safe: tasks.read only.
  • Audience: https://mcp.taskco.com.
  • Token lifetime: 1 hour, refresh rotation.

Endpoints to ship:

  • https://mcp.taskco.com/mcp (the MCP endpoint).
  • https://mcp.taskco.com/.well-known/oauth-protected-resource (PRM).
  • https://auth.taskco.com/.well-known/oauth-authorization-server (AS metadata, exposed by WorkOS).

The 401 response shape:

http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.taskco.com/.well-known/oauth-protected-resource",
                         scope="tasks.read"
Content-Type: application/json

{"error": "invalid_token", "error_description": "Missing or invalid bearer token"}

The PRM response shape:

json
{
  "resource": "https://mcp.taskco.com",
  "authorization_servers": ["https://auth.taskco.com"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["tasks.read", "tasks.write", "projects.read"]
}

The token validation, ~15 lines of TypeScript:

ts
import { jwtVerify, createRemoteJWKSet } from "jose";

const jwks = createRemoteJWKSet(new URL("https://auth.taskco.com/.well-known/jwks.json"));
const EXPECTED_AUDIENCE = "https://mcp.taskco.com";
const EXPECTED_ISSUER = "https://auth.taskco.com";

async function validateToken(bearer: string) {
  const { payload } = await jwtVerify(bearer, jwks, {
    issuer: EXPECTED_ISSUER,
    audience: EXPECTED_AUDIENCE,
  });
  if (!payload.scope || typeof payload.scope !== "string") {
    throw new Error("token missing scope claim");
  }
  return {
    userId: payload.sub as string,
    scopes: payload.scope.split(" "),
  };
}

That's the minimum: signature, issuer, audience, and a scope claim that you can map to permission checks per tool.

The thing this snippet doesn't show, that you should also do: log the validation outcome (accept or reject, with reason). Logging successful validations is just as important as logging rejections. The audit story depends on it.

The shorter version

Most MCP servers shipped in 2026 fail a basic security scan. The failures are not exotic. They're missing audience validation, no SSRF protection on URL-accepting tools, command injection in shell-out tools, and credentials in config files.

The auth checklist is short. The discipline is hard. Pick stdio only if your server holds no credentials worth stealing; pick HTTP with full OAuth otherwise. Validate the audience on every token. Treat tool arguments as hostile inputs. Log everything. Ship the minimum viable version with those four things and iterate, instead of trying to land the full spec in week one.

Priyansh Khodiyar

Priyansh Khodiyar

Maintainer

Works on authsome and the agentr.dev tooling.