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 NOTfollow the authorization spec. Credentials come from the environment. - HTTP transport
SHOULDconform 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:
- The server holds no third-party credentials at all (the seven official reference servers are like this), or
- The credentials it holds are bounded in blast radius to what the OS user already has (e.g., a
Gitserver that uses the user's existing~/.ssh/key), or - 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:
{
"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:
- Protected Resource Metadata at
/.well-known/oauth-protected-resource(RFC 9728), pointing at an authorization server. - OAuth 2.1 (draft-13) with PKCE S256.
- Resource Indicators (RFC 8707). Every token request
MUSTinclude theresourceparameter. Every token you issueMUSTbe audience-restricted to your server. Tokens missing the audience claimMUSTbe rejected. - WWW-Authenticate header on every 401 with
resource_metadata="...". - CIMD (draft-ietf-oauth-client-id-metadata-document-00)
SHOULDbe supported for unknown clients. DCR (RFC 7591)MAYbe 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
MUSTsections 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-resourcereturning 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: truein 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
audclaim matches your audience identifier. This is the Resource Indicator check. The MCP spec is explicit: tokens missing the audience claimMUSTbe 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
jtifor 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
/registerendpoint 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
.envfile 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:
- HTTPS.
- OAuth flow, even if it's a hardcoded shared OAuth app you reuse across all users.
- Audience validation on every token.
- 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.readonly. - 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/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:
{
"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:
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.
Next steps
Quickstart
Install authsome and route your MCP server's outbound credentials through a vault.
MCP server authentication, ranked
The companion: seven MCP auth approaches scored, with the spec language for each.
What is MCP? A developer's primer
The protocol from the ground up: architecture, transports, and where this fits.
Further reading
MCP server authentication in 2026: seven approaches, ranked by where the credential lives
From no-auth localhost to CIMD to credential brokers, seven ways MCP servers handle auth in 2026. Real ranking criteria, current spec status, and an honest look at what production servers actually ship.
Read postMay 26, 2026The agent auth wave: auth.md, ID-JAG, AAuth, CIMD, and why OAuth is finally growing up in 2026
Six months ago, agent authentication was a gap in the OAuth ecosystem. As of May 2026, five separate efforts are converging on a coherent shape. Here is the map, the timeline, and what is still missing.
Read postMay 26, 2026MCP gateways in 2026, compared: the twelve you should actually know about
Twelve real MCP gateways in 2026, sorted by deployment model and use case. Self-hosted open source first, then hosted SaaS, then cloud-platform managed. With an honest take on what is and isn't actually a gateway.
Read post