Why a Valid Token Is Not Enough: Securing a Remote MCP Server in Production
Updated: Sep 30


The Model Context Protocol (MCP) lets a Claude connector call tools on a server you host. MCP server security usually starts with authentication: sign the user in with OAuth, issue a token, check it on every request.
For this project, authentication was only the first layer. For a wholesale distribution client, TriSeed built a remote MCP server that lets authorized staff ask Claude plain-language questions about order-entry and inventory data mirrored from their ERP. One question shaped the design: if a valid token leaked, what could someone do with it?
The problem with bearer tokens
An OAuth access token is a bearer credential. Whoever presents it is treated as the person it was issued to. The server can check that the token is validly signed and unexpired, but it cannot tell whether the request came from the Claude connector or from a laptop somewhere else that happens to hold a copy.
Identity checks answer who is asking. They do not answer where the request is coming from. For a server that exposes business data, we wanted both, plus a hard limit on what any caller could read even after passing every check.
Start with what the server cannot do
The server exposes three tools and nothing else:
list_tables returns only the tables the caller's group is allowed to read.
describe_table shows a table's columns and types.
query_table reads rows, with optional column selection, a row limit, and simple filters (equals, case-insensitive pattern match, and value lists).
There is no write tool. Read-only is the first security control, not a feature. A tool that cannot write cannot be talked into writing, whatever the prompt says. The query tool takes structured inputs only (table, columns, filters, row limit), not SQL.
It also matters for how Claude uses connectors. Anthropic's guidance for custom connectors notes that Research can invoke connector tools automatically, without further approval, and recommends disabling tools that can take write actions. A server with no write tools removes that concern by design.
Read-only limits what a caller can change. It does not limit what an authorized caller can see, and it does not remove every model-level risk. Any free-text field read into the conversation could contain instructions aimed at the model (indirect prompt injection). That risk is worth reviewing for any connector that reads business records.
Four MCP server security gates, in order
Every request to the MCP endpoint passes through four checks. Each one is enforced independently, and the first one that fails ends the request.

Gate 1: Source IP at the edge
When a user adds a custom connector, Claude connects to the remote MCP server from Anthropic's cloud infrastructure, not from the user's device. Anthropic's Help Center notes this is true across every Claude client, including Claude Desktop. Anthropic publishes the outbound range used for MCP tool calls to external servers, 160.79.104.0/21, and states that its addresses will not change without notice.
That rules out allowlisting the office or VPN range. A Claude connector never arrives from there. The only network that should reach the server is Anthropic's. We enforce that range on the web application firewall in front of the service, as two ordered rules: block any request whose source address is outside the range, then allow what remains through to the service mesh.
Because the firewall sits at the public edge, it sees the true client address directly, and off-range traffic never reaches the application. Opening the connector's range is not a relaxation of security. It is the minimum surface the product needs, surrounded by a default deny. The open range grants reachability, not access. In-range requests still have to pass everything that follows.
The open range grants reachability, not access.
The IP gate only proves the request came through Anthropic's connector network. It says nothing about who the user is, which is why the identity and group gates behind it carry the real weight.
Gate 2: A valid identity token, with MFA
Users sign in through Microsoft Entra ID using OpenID Connect with multi-factor authentication, and the connector receives a JWT. Apart from the public discovery and health endpoints below, every request needs a valid token. The service validates it against Entra's published signing keys, and a missing, malformed, or expired token is rejected.
The MCP authorization specification requires servers to publish OAuth 2.0 Protected Resource Metadata (RFC 9728), so a client can discover which authorization server to use. This server publishes it at the well-known path (/.well-known/oauth-protected-resource), which has to stay reachable without a token so the connector can find the authorization server before the user signs in. Health probes are also exempt.
Gate 3: Group membership
A valid token proves who the user is. It does not prove they should see anything. The service reads the token's groups claim and maps each security group to a role using a single configuration file. A user's access is the union of the roles their groups map to. A user in no mapped group gets nothing.
There is an edge case worth designing for. When a user belongs to too many groups, Entra leaves the groups out of the token and adds an overage claim pointing to Microsoft Graph instead. Microsoft documents the limit as 200 groups for JWTs (150 for SAML). Rather than guess, the server treats that as deny-all and logs the event. Failing closed is the right default, and keeping group membership lean avoids the problem in the first place.
Gate 4: Table-level scope and database grants
Each role lists exactly which schema and tables it may read, so different groups see different sets of tables. A query against a table outside the caller's roles returns nothing.
The same configuration file also generates the grants for the service's single database account, through a small script. That account can only run SELECT, and only on tables that appear somewhere in the file. It cannot write, change the schema, or read anything unmapped. Per-team separation between roles is enforced in the application layer, because one shared account cannot tell callers apart. The database grant is a ceiling on the whole service; the role mapping decides what each caller gets beneath it.
The detail that decides whether the IP gate works: trusted proxy headers
We enforce the allowlist in two places as defense in depth: at the firewall, which sees the true client address, and again as an IP policy inside the service mesh.
When a gateway sits in front of a service mesh, the mesh no longer sees the client's address directly. It reads it from the X-Forwarded-For header, and it has to know how many proxy hops to trust. In Istio this is the numTrustedProxies setting in the gateway topology configuration.
Get it wrong in one direction and the service trusts whatever address the caller writes into the header, so anyone can claim to be inside the allowed range. Get it wrong in the other direction and the service matches the gateway's own address, which blocks everyone. Either way, the IP gate stops being a control.
So we tested it the way an attacker would. From a blocked address, we sent requests with a forged X-Forwarded-For naming an in-range address, a forged chain of addresses, and a forged X-Real-IP. All three had to return 403. If any of them returns 200, the gate is decoration.
How we verified it
We built the verification plan as a matrix that changes one variable at a time, so every result points to exactly one gate. A source address cannot be forged from a test client, so boundary and real-world cases are run from actual networks, while spoofing cases are run from a blocked address with forged headers.

Shipping changes without surprising production
Changes to which groups can read which tables go through the same path as a code change.
The server code, the Helm chart, and the access configuration live in one repository. A pull request runs chart validation and a build-only image check, and nothing deploys. Merging to main does not deploy either. A release only happens when someone cuts a version tag. The pipeline builds the image for that tag and updates the Argo CD Application to track that Git tag. Argo CD runs with self-heal and prune enabled, so any manual edit to the live cluster is reverted to the pinned version.

For the business, this means every change to the group and table mapping has a reviewer, a timestamp, and a version it can be rolled back to, and a manual edit to production does not stick. Who belongs to each group is managed separately, in Entra ID.
What these controls buy you
A leaked token is not enough. Presented from anywhere other than the connector's network, it is refused at the edge.
Least privilege by default. Users see only the tables their group needs, and the database account can never read beyond the tables the service is configured for.
No database credentials in anyone's hands. Users get plain-language access to the data without ever holding a connection string.
Auditable mapping changes. Which groups can read which tables changes only through a reviewed, versioned release.
Lessons from the rollout
The most common issues users hit were sign-in related, and they are worth documenting for any team rolling out a connector like this:
Disconnects are almost always an expired sign-in session. A laptop sleeps or changes networks, the session lapses, and the connector shows as disconnected. Reconnecting and signing in again fixes it.
The wrong Microsoft account fails before any gate. If a user picks an account from outside the client's tenant, Entra rejects it before a token is ever issued. What decides success is the work account chosen in the Microsoft picker, not the Claude account. Outside users can be added as guests when needed.
Sources
Moving a Claude pilot into production?
TriSeed, a Claude Partner Network member, builds and hardens Claude integrations that touch real business data.





Comments