Securing MCP Resources: OAuth 2.1 and Scoped Token Enforcement

Written by

in

# Securing MCP Resources: OAuth 2.1 and Scoped Token Enforcement

![OnlineCloud secure MCP architecture with OAuth 2.1 and scoped token enforcement](https://www.onlinecloud.dev/assets/hero-company-V8Xps1Ae.png)

*By the security engineering team at OnlineCloud — a US-based Agentic AI & MCP engineering studio*

—

## 1. Introduction: The Vulnerability of Agentic Data Access

Every agentic system ships with the same silent assumption: *the model will only do what we asked it to do.* That assumption is the vulnerability.

Think about what an MCP (Model Context Protocol) server actually is. It is an authenticated door from an LLM — a stochastic, prompt-injectable, eminently socially-engineerable principal — directly into your resource estate: `customers/{id}/orders`, `finance/invoices`, `hr/records`. In the pre-agentic world, a human clicked through an application, and the application’s UI was the permission boundary. In the agentic world, the model proposes tool calls autonomously, in a loop, often over long-running sessions where a single malicious string inside a fetched document can steer the next invocation. The UI is gone. The boundary must be rebuilt at the protocol layer.

The uncomfortable truth is that **an MCP resource URI is a permission**. `mcp://ledger/orders/4421` is not merely an address; it is a statement of what a caller may read or mutate. When a host connects to an MCP server without authentication, without per-request authorization, and without a notion of *whose* access is being exercised, then every resource the server exposes is available to every agent that reaches it — and every prompt injection becomes a data exfiltration primitive.

This is why the security architecture of MCP cannot be an afterthought bolted onto a working demo. It must answer five questions before the first tool call ships:

1. **Who** is the human ultimately responsible for this action?
2. **What** capability does this specific agent invocation require?
3. **How** does the server verify the caller on every request, not just at connection time?
4. **Where** does identity terminate, so the database can enforce row-level policy?
5. **What** evidence is left behind for auditors and incident responders?

OAuth 2.1 answers the first three. Scoped token enforcement answers the second and the fourth. Audit logging answers the fifth. This post walks through each, and closes with a reference architecture that binds them together inside a VPC-isolated MCP deployment. I write this as a security architect who has spent fifteen-plus years shipping data systems against Oracle, MS SQL Server, Postgres, and Snowflake — because the agentic era does not forgive the mistakes the API era tolerated.

## 2. OAuth 2.1 for MCP: Implementing the Authorization Code Flow with PKCE

OAuth 2.1 is not a new protocol so much as a **consolidation of the security requirements** that the industry spent a decade learning the hard way: PKCE is mandatory for every client, implicit grants are abolished, refresh tokens for public clients are restricted, and every grant must be bound to a client through sender-constrained tokens. For MCP, this matters because the MCP Authorization specification — now part of the broader MCP ecosystem — adopts OAuth 2.1 as its native authorization layer, and the new Streamable HTTP transport is explicitly designed to carry it.

The pattern to implement is the **Authorization Code Flow with PKCE** (Proof Key for Code Exchange). The flow, as it applies to an MCP host:

1. The user opens the agent host and the host discovers the MCP server’s authorization metadata via `/.well-known/oauth-authorization-server`.
2. The host generates a `code_verifier` (a high-entropy random string) and its SHA-256 hash, the `code_challenge`, which it sends with the authorization request to the authorization server. The plaintext verifier never transits the wire.
3. The user authenticates and consents at the authorization server, which returns an authorization code — not a token — to the redirect endpoint.
4. The host exchanges the code for tokens at the token endpoint, presenting the `code_verifier`. The server hashes it and compares against the stored challenge. **This is the PKCE guarantee:** even if an attacker intercepts the authorization code, they cannot redeem it without the verifier, which never left the host.
5. The host receives an access token with a short TTL and a refresh token bound to the client.

Three implementation rules separate a compliant deployment from a decorative one.

**Rule one: PKCE is never optional.** In OAuth 2.1 it is required for public clients, and you should require it for confidential clients too. There is no code path where PKCE is skipped because “this client is trusted.” MCP hosts are frequently desktop or mobile applications — precisely the public-client category where static client secrets are worthless and PKCE is the only credential the host can prove.

**Rule two: tokens are short-lived, and refresh tokens rotate.** Access tokens in an MCP context should live minutes, not hours — long enough for a bounded task, short enough that a leaked token in a prompt-injection artifact is stale before it is useful. Refresh tokens must be single-use: every refresh returns a new refresh token and invalidates the old one, so a stolen refresh token dies at the next rotation. Never issue a refresh token that outlives its session.

**Rule three: every request is authenticated, not every connection.** Streamable HTTP sessions must not smuggle tokens into a connection and then assume the session is authorized. Each tool call carries its token (in practice, a short-lived token refreshed on demand), and the MCP server validates signature, issuer, audience, expiry, and scope **per request**. A connection that was authorized at 9:00 AM is not authorized at 9:00 AM plus one revoked session.

Concretely, the server-side validation on every request should check, in order: signature validity, `iss` and `aud` claims against the configured issuer and the server’s own client ID, `exp`, and then the scope claim against the tool or resource being invoked. Fail any check, return a structured error, and emit an audit event. That final step — logging the rejection — is what turns authentication from a gate into evidence.

## 3. Scoped Tokens: Mapping Model Capabilities to Granular Resource Permissions

Authentication answers *who you are*. Authorization answers *what you may do*. In OAuth terms, authorization is expressed as **scopes**, and scopes are where MCP security is won or lost — because the granularity of your scopes is the granularity of your containment.

The cardinal sin in agent deployments is the coarse scope. A token that carries `data:all` or `resources:read_write` gives the model a master key, and a master key in the hands of a stochastic principal is not authorization — it is a blast radius. The model does not need to *want* to exfiltrate data; it needs only to be successfully instructed to.

Scope design for MCP must be **capability-shaped, resource-shaped, and operation-shaped** simultaneously. A token’s scope should read like a sentence describing the smallest useful action: `orders:read:own`, `invoices:read:own`, `refunds:create:under_500`. Not `orders`, not `read`, not a wildcard namespace. The mapping from model capability to scope is a deliberate design act: enumerate the tools your agent genuinely needs, decompose each into the resource class it touches and the operations it performs, and mint a scope per (operation, resource-class) pair. If a tool needs to read a customer’s order history to answer “where is my package,” the token scope is `orders:read` for that user’s rows — not `orders:read` for every row in the warehouse.

Two enforcement mechanisms make scopes real at the MCP layer.

**First, scope-to-tool mapping at the server.** Each MCP tool declares the scope(s) its invocation requires, and the server rejects any call whose token lacks them *before* the tool handler runs. This is a declarative allowlist, enforced in one place:

“`json
{
“tools”: {
“get_order_status”: {
“required_scopes”: [“orders:read:own”, “shipments:read:own”],
“resource_surface”: “mcp://ledger/orders/{order_id}”
},
“create_refund”: {
“required_scopes”: [“refunds:create:under_500”],
“human_checkpoint”: “refund > $500”
}
}
}
“`

**Second, resource-scoped authorization at the data layer.** The scope claim answers “may this caller invoke `get_order_status`?”; a second question — “may this caller read *order 4421*?” — must be answered by row-level security keyed to the propagated identity, which is the subject of the next section. Scope grants the *capability*; identity governs the *rows*.

One further refinement: in high-assurance deployments, exchange the coarse login token for a **task-scoped delegation token** at the start of each agentic run. OAuth 2.1 token exchange lets the orchestrator present its token and receive a narrower one — tighter scopes, shorter TTL, an explicit `aud` of the single MCP server, and a `delegation` claim chain naming the initiating user. The model never holds the user’s token; it holds a perishable, narrow proxy for it. This is the difference between “the agent is the user” and “the agent acts *for* the user, within limits that expire.”

## 4. Identity Propagation: How to Flow User Identity from Client to Tool

Here is the failure mode that keeps security architects up at night: the user authenticates beautifully at the front door, and then the agent calls the database with a **service account**. Every row-level policy, every audit trail, every least-privilege grant designed around the human evaporates the moment the model’s tool call executes as `mcp_svc_prod`. The database sees a single privileged principal with a thousand concurrent sessions, and “who did this?” becomes unanswerable.

Identity propagation for MCP means the end user’s identity — not the model’s, not the server’s service account — must survive the journey from client to tool to database, as an **enforceable and auditable claim**. The mechanism is a delegation chain carried in the token and forwarded at each hop.

At the authorization server, mint the access token with claims that describe the full context of the call:

“`json
{
“iss”: “https://auth.onlinecloud.internal”,
“aud”: “https://mcp.ledger.internal”,
“sub”: “delegated:task-8f3a…”,
“scope”: “orders:read:own refunds:create:under_500”,
“exp”: 1713000000,
“delegation”: [
{ “actor”: “user:alice@example.com”, “auth_time”: 1712999400, “amr”: [“pwd”, “webauthn”] },
{ “actor”: “client:finance-agent:v3”, “purpose”: “invoice reconciliation run” }
]
}
“`

The MCP server then does two things with these claims. It **enforces** them — parsing the innermost `actor` and passing it to the data layer so that row-level policies evaluate against *Alice*, not against the service account — and it **forwards** them in the audit record of every tool invocation.

In the data layer, enforcement takes the shape of your database’s native RLS mechanism. In Postgres, the server sets `current_setting(‘app.caller_id’)` and `app.caller_chain` at the start of the session or transaction, and every policy evaluates against that setting. In SQL Server, `SESSION_CONTEXT` plus security predicates achieve the same. In Snowflake, row-access policies key on the propagated identity column. The invariant in every case is identical: **the database never executes a query whose effective principal is the service account.** It executes queries whose effective principal is Alice, mediated by an agent the system can name.

Two hard rules govern propagation. **Rule one: propagate, never substitute.** If the chain is ever broken — if a hop loses the user claim and falls back to a generic principal — that hop fails closed and logs an alert. A tool that cannot prove whose data it is touching does not get to touch it. **Rule two: delegation never escalates.** The effective permission at any hop is the intersection of the user’s grants and the agent’s scopes. An agent cannot exercise a permission the user lacks, and a user grant cannot be widened by the agent’s token. The confused-deputy problem — a privileged agent tricked into acting on behalf of an unprivileged user, or an unprivileged user’s prompt injection steering a privileged agent — is neutralized because neither side can grant what the other does not hold.

## 5. Audit & Compliance: Logging Authenticated Resource Access in MCP

Auditors do not ask whether your agent is clever. They ask a far more uncomfortable question: **can you prove what it did, under whose authority, and why?** For regulated workloads — SOC 2, HIPAA, SOX, GDPR — an agentic system that cannot produce a replayable record of authenticated resource access does not have a compliance story; it has a liability.

Audit logging for MCP must therefore capture the *full authorization context* of every tool invocation, not merely “a call happened.” The minimum event schema:

– **Who**: the propagated delegation chain — initiating user, client/agent, orchestration run ID.
– **What**: tool name, validated input arguments, resource URIs touched.
– **Authorization**: token ID, scopes presented, which scope satisfied the call, token issuer.
– **Outcome**: success, or the structured rejection reason (expired, wrong audience, insufficient scope, RLS denial).
– **When and where**: timestamp with timezone, server instance, transport session ID.
– **Parent linkage**: the event ID of the agentic step that spawned this call, so the log forms a tree — from user intent down to the row that changed.

Two properties make the trail trustworthy. **Completeness:** logging must live *inside* the authorized request path, not around it. If a tool call can succeed without emitting an audit event, then under load, unlogged calls will happen — and the one that matters will be the missing one. **Immutability:** the audit sink is append-only and written with credentials no agent token can reach. The trail must survive the very compromise it is designed to detect. Ship audit events to a separate store — an independent log stream or a write-only data source — that sits outside the blast radius of any MCP server compromise.

Compliance also demands an answer to the *human* question: which human authorized this system behavior, and which human approved the irreversible actions? Log the consent event at the authorization server (user X approved scopes S for client C at time T), and log human checkpoints at the tool layer — the operator ID, the approval timestamp, and the exact payload they approved. When a regulator asks “who said this refund of $4,000 was acceptable?”, the answer is a signed audit event, not a guess.

Finally, build the **replay** capability before you need it. Store enough structured context per event — the tool schema version, the model and prompt version, the parent plan — that an investigator can reconstruct the decision path, aborted branches included. Proving intent requires showing what the agent *considered*, not only what it executed. An MCP deployment whose audit trail supports this replay is a deployment that walks into an assessment and demos its own compliance.

## 6. Reference Architecture: A Secure, VPC-Isolated MCP Auth Flow

The pieces above compose into a single, defensible architecture. Here is the deployment OnlineCloud runs for regulated customers — the same pattern we would run for you.

“`
┌─────────────┐ ┌──────────────────────────────────────────────────────┐
│ End User │ │ Customer VPC │
│ (Browser / │ │ │
│ Desktop) │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
└──────┬──────┘ │ │ Agent Host │───▶│ MCP Server │──▶│ Database │ │
│ │ │ (OAuth client)│ │ (authz gate) │ │ (RLS) │ │
│ │ └──────┬───────┘ └──────┬───────┘ └──────────┘ │
│ │ │ 1. auth request │ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
└──────────┼──│ Authorization │ │ 2. validate JWT: iss, │
│ │ Server (IdP) │ │ aud, exp, scope, │
│ │ PKCE + consent │ │ delegation chain │
│ └──────────────────┘ │ │
│ │ 3. exchange │ 4. RLS keyed to │
│ ▼ │ propagated user │
│ ┌───────────────────────────────┐ │
│ │ Audit Store (append-only) │◀── every call logged │
│ └───────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
“`

The numbered flow: **(1)** the end user initiates a session; the agent host begins the OAuth 2.1 Authorization Code flow with PKCE against the internal authorization server, and the user consents to a narrow scope set. **(2)** The host receives a short-lived access token whose claims carry the delegation chain from step 4 of this post — user, agent, purpose — and whose audience is pinned to the single MCP server. **(3)** The host invokes tools over Streamable HTTP inside the VPC. Every request terminates at the MCP server’s authorization gate, which validates signature, issuer, audience, expiry, and scope against the requested tool, then rejects or forwards. **(4)** For data access, the server sets the propagated identity in the database session context and lets row-level security evaluate against the *user*, so the model can only ever reach rows the initiating user could reach. Every step — authorization, token exchange, tool call, RLS decision, rejection — streams an immutable event to the append-only audit store, which lives outside the reach of any agent credential.

The structural guarantees are worth naming explicitly, because they are *network facts*, not prompt instructions:

– **No public ingress.** The MCP server, authorization server, and audit store live in private subnets. The database is reachable only over private endpoints. The model — Claude, GPT-4-class, or an open-weights deployment in your own VPC — reaches the MCP server, and nothing else.
– **No service-account data access.** The database never executes a query whose effective principal is a shared agent credential. Every query is evaluated against a propagated human identity and an audited delegation chain.
– **No unlogged invocation.** A tool call that succeeds without emitting an audit event is architecturally impossible, because logging lives inside the request path.
– **No standing privilege.** Tokens are short-lived, refresh tokens rotate, and each task runs on a delegation token narrower than the user’s own grant.

Get these four guarantees right, and the agentic system becomes *containable*: the most capable tool your business has shipped, bounded by identity, scope, and evidence at every hop. Get them wrong, and you have deployed your largest attack surface with the user’s credentials attached.

—

**Planning an MCP rollout — or securing one that already shipped? Talk to the OnlineCloud architects.** We are a US-based engineering studio — MCP-native, senior engineers only, no offshore handoff — and we deliver evaluated, observable, VPC-isolated agentic systems with audited OAuth 2.1 authorization in production within 6–10 weeks. Contact us at [contact@onlinecloud.dev](mailto:contact@onlinecloud.dev) or visit [onlinecloud.dev](https://www.onlinecloud.dev/) for a free initial consultation. We respond within 24 hours.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *