Hardening an MCP server: from a private allowlist to enterprise controls
· mcp, security, oauth, llm, compliance
The numbers on MCP security are not encouraging. Roughly a quarter of public MCP servers require no authentication at all. An offensive-security assessment found 43% of tested servers vulnerable to command injection. An audit of more than 5,200 servers found 79% passing credentials through environment variables, and only 8.5% using OAuth. More than thirty CVEs were filed against MCP servers in a single sixty-day window earlier this year.
That's the baseline. Anything you do is an improvement on it, which is a low bar and also a real
one, because the median MCP server is a stdio script somebody wrote in an afternoon and then
exposed over HTTP without changing anything else about it.
I want to lay out three tiers of hardening, because most writing on this jumps straight to the enterprise answer and leaves you with nothing actionable on a Tuesday. I built the first tier. I've implemented most of the second. The third is what you need when the blast radius stops being yours alone.
The organizing principle throughout: match the tier to what the tools can reach. A read-only market-data server for four people you know personally deserves a different posture than an agent with write access to customer records.
Tier 1: alpha, for people you can name
This is what I shipped for the tastytrade MCP server. It serves a handful of people I know, and the identity system is deliberately small.
I maintain an allowlist.json of email addresses. I mint a magic invite link for someone on that
list and hand it to them over Signal or Discord. They open it once in a browser, which sets a
cookie, then they add the MCP URL to their client (Claude, ChatGPT, etc.) and the OAuth flow completes against that session.
Every tool call after that is audited against their email.
me: allowlist + npm run invite -- friend@x.com -> magic URL
friend: opens /invite?token=... (cookie set)
friend: adds https://.../mcp to their client (OAuth completes)
audit: mcp_audit.email = friend@x.com
What this actually proves: that whoever redeemed the link controls the browser that redeemed it. It does not cryptographically prove mailbox ownership. Nobody sent a verification email, because there's no mail provider in the stack. The link binds a connector to an identity that a human and I agreed on in a private channel.
I think that's the honest description, and I'd rather ship a small system I can describe accurately than a login flow whose guarantees I'd have to talk around.
Three other things carry more weight than the identity layer:
The upstream credential is read-only. The OAuth grant at the brokerage has read scope and no
trade scope was ever requested. If the allowlist, the invite system, and the MCP server all failed
at once, the credentials still cannot place an order. This is the control I'd keep if I could keep
only one.
No write tools exist. Not disabled, not gated behind confirmation. Absent. Every tool is
annotated readOnlyHint: true, openWorldHint: false. The annotations are a hint to the client; the
absence of the tool is a fact about the server.
Everything is audited. Full JSON of arguments and results, per call, keyed to the caller's email, with a session-grouped viewer. When somebody says the assistant told them something strange, the question worth answering is what the model had, and that's the only way to answer it.
Where Tier 1 breaks
Be clear-eyed about this, because "it works for my friends" quietly becomes "it's in front of customers" without anyone deciding.
- The invite URL is a bearer token. Anyone who gets the link is that person. It's reusable until it expires.
- Revocation means editing a file. Removing someone is a JSON edit and a reload. There is no session invalidation, so an already-connected client keeps working.
- There is one admin, and it's me. No separation of duties, no second pair of eyes on an access change.
- Trust on first use, forever. Nothing re-verifies that the person on the other end is still the person I handed the link to.
- The audit log is evidence for me, not for anyone else. It's a directory of JSON on a disk I control. I could edit it. That's fine when the auditor is me and unacceptable the moment it isn't.
Tier 2: intermediate, real OAuth done correctly
The jump here is architectural. Your MCP server stops being an identity system and becomes an OAuth resource server, with a real authorization server handling authentication and token issuance. Getting that boundary right is what everything else depends on.
The MCP authorization spec is explicit about the split, and it comes with a small set of checks that kill entire vulnerability classes.
Validate the audience. This is the whole ballgame.
The spec states flatly that MCP servers MUST NOT accept any tokens that were not
explicitly issued for the MCP server. Concretely: reject any token whose aud claim is not your canonical resource
URI.
const claims = await verify(token, jwks);
if (claims.aud !== CANONICAL_RESOURCE_URI) {
throw new Unauthorized("token audience does not match this resource");
}
That one check kills the entire token passthrough class. Passthrough is when a server accepts a token from a client without validating who it was issued to, then forwards it unmodified to a downstream API, turning your server into a free credential relay. A token minted for some other service is useless against you once you check the audience. It costs four lines.
The security best practices document walks through why this matters beyond the obvious: passthrough breaks rate limiting and request validation that key off the audience, and it wrecks your audit trail, because the downstream service logs an identity that isn't yours.
The confused deputy needs its own fix
Worth separating, because audience validation does not solve this one and plenty of writing implies it does.
The MCP-specific confused deputy attack applies to proxy servers that sit in front of a third-party
API. It needs four conditions together: your server uses a static client ID with the third-party
authorization server, you let MCP clients register dynamically, the third-party sets a consent
cookie after the first authorization, and you don't run your own per-client consent step. Given
those, an attacker registers a client with their own redirect_uri, sends the user a crafted link,
the third-party sees the existing consent cookie and skips the consent screen, and the authorization
code lands on the attacker's server.
The mitigation is consent you own: a per-client consent registry checked before you forward anyone
to the third party, exact-string redirect_uri matching with no wildcards, single-use state
values stored only after consent is approved, and __Host- prefixed cookies with Secure,
HttpOnly, and SameSite=Lax.
The rest of the Tier 2 checklist
- OAuth 2.1 with PKCE, mandatory, no implicit flow.
- Protected Resource Metadata (RFC 9728) so clients can discover which authorization server to talk to instead of guessing.
- Resource indicators (RFC 8707) so tokens are minted for a specific resource in the first place, which is what makes audience validation meaningful.
- Never forward the caller's token downstream. Your server holds its own upstream credential, scoped as narrowly as the upstream allows. The caller's identity determines whether you act. Your credential determines how.
- Short-lived access tokens with refresh, so revocation has a bounded window rather than depending on you remembering to invalidate something.
- Per-identity rate limits. An agent in a retry loop looks exactly like abuse, and you want the blast radius of that scoped to one caller.
- Structured audit with request IDs that correlate across the MCP server and every downstream call, so a single trace answers "what did this user's session actually touch."
Tier 2 is where I'd draw the line for anything with real users who don't have your phone number. It is achievable in a week with an off-the-shelf identity provider, and it removes the entire category of problems that come from having hand-rolled identity.
Tier 3: enterprise, where the controls have to be auditable
The Tier 3 jump is mostly a change in audience. Your security posture has to be legible to somebody who does not trust you: an enterprise customer's security review, a SOC 2 auditor, a covered entity's privacy officer.
Aptible is a useful reference point here, because its whole product is running infrastructure that satisfies exactly these reviews. Its public model manages information security consistent with SOC 2, HITRUST, HIPAA, and GDPR, with SOC 2 Type 2 and HITRUST CSF reports available to customers under NDA. Looking at what that requires tells you what Tier 3 means in practice.
Identity comes from the customer's directory. SSO, enforced MFA, and SCIM provisioning. The
critical half of SCIM is deprovisioning: when someone leaves the customer's company, their access
disappears without anyone filing a ticket with you. My allowlist.json cannot do that, and no
amount of polish makes it able to.
Authorization is named roles, deny-by-default. Aptible's model ties roles to specific environments and operation types rather than shared credentials or blanket permissions. For an MCP server that means a caller's identity resolves to a role, the role grants specific tools against specific resources, and anything not granted is denied. Not "admin and everyone else."
Every action attributes to a named principal. A named user account or a service token, never a shared credential. The moment two humans use one credential, your audit log has stopped being evidence.
Audit logging becomes a control you get graded on. It's required under the HIPAA technical safeguards at 45 CFR 164.312(b) and appears in HITRUST and SOC 2 as well. That changes its requirements: append-only or write-once, retained on a defined schedule, tamper-evident, exportable in a form somebody else will accept. My directory of JSON files satisfies none of that.
Secrets live in a broker. Short-lived, narrowly scoped credentials issued per operation, rather than long-lived keys sitting in environment variables. Nothing in the system ever holds a root credential, so nothing in the system can leak one.
A gateway inspects tool calls. At enterprise scale the security team wants a policy enforcement point in front of the MCP server that can log, redact, and block specific calls without redeploying the server. This is the piece the current spec doesn't address, and it's why enterprise MCP deployments keep growing a proxy in front.
Blast radius becomes an explicit design input. Separate the control plane from the data plane. Have callers reference logical identifiers that you resolve internally, so physical resource names never appear in a tool argument or a response. Tenant isolation checked on every call, not at the edge.
Choosing a tier without kidding yourself
The failure mode I see most often is building Tier 3 ceremony around a Tier 1 problem, running out of energy, and shipping nothing. The second most common is Tier 1 auth quietly ending up in front of customers because nobody ever decided to change it.
Three questions settle it:
- What can the tools reach? Read-only public market data and a customer database are not the same server, whatever the auth looks like.
- Can you name every user? If yes, Tier 1 is defensible and honest. If you're onboarding people you've never spoken to, you're at Tier 2 whether or not you've built it.
- Will somebody who doesn't trust you need to verify this? The instant the answer is yes, you're at Tier 3, and the work is mostly about evidence rather than mechanism.
The upgrade path, in order
If you're at Tier 1 and know you'll outgrow it, this is the sequence that buys the most safety per unit of work:
- Scope the upstream credential down. Read-only if the tools only read. This survives every other failure and usually takes an afternoon.
- Delete write tools you aren't using. An absent tool cannot be misused.
- Move identity to a real IdP and validate the audience claim. Biggest single jump on the list.
- Stop forwarding caller tokens anywhere.
- Make the audit log append-only and put it somewhere you cannot casually edit.
- Add SCIM when your first customer asks how offboarding works, which they will.
Steps 1 and 2 are available to you today and cost almost nothing. I'd argue they matter more than anything in Tier 2, because they change what a total compromise can accomplish rather than how hard a compromise is.
That's the thread running through all of this, and it's the same one from the post about what my agent was actually reading: how confident you feel about the system matters far less than what the system can still do to you when you're wrong.