Topic: ambient authority security
MCP server ambient authority security — implicit permissions, capability delegation, per-call authorization
Ambient authority is when a program can act on permissions it holds implicitly — not because someone explicitly authorized this particular action, but because the program was launched with those permissions in its environment. In MCP servers, ambient authority is the rule rather than the exception: a server configured with a GitHub token at startup can call any GitHub API the token allows, for any tool call, for the lifetime of the session — regardless of what the LLM or user actually intended. A prompt injection attack exploits exactly this: the attacker doesn't need to steal the credential; they just need to convince the LLM to call a tool. This page covers five patterns to replace ambient authority with explicit per-call credentials, scoped capability objects, confused deputy prevention, authority attenuation, and call-site permission logging.
1. Explicit per-call credentials
The canonical ambient authority anti-pattern: a module-level API key used inside a tool handler. The key is present in the process environment for every tool call, for every session, for the server's entire lifetime. There is no record of which tool calls used it, no per-call authorization check, and no way for the LLM or user to limit its scope at call time.
Where the API supports it, the alternative is to include a credential or scope indicator in the tool's argument schema, making authority explicit in the protocol. For tools where per-call credentials are not practical (the credentials come from server configuration, not from the user), the minimum alternative is an explicit authorization check that gates each call on session-level identity.
// AMBIENT AUTHORITY ANTI-PATTERN
// The token is available to every tool handler — no per-call gate
const GITHUB_TOKEN = process.env.GITHUB_TOKEN // module-level, ambient
async function listIssuesHandler(args: { owner: string; repo: string }) {
// The LLM chose which repo to read — and the ambient token has access to ALL repos
const res = await fetch(`https://api.github.com/repos/${args.owner}/${args.repo}/issues`, {
headers: { Authorization: `Bearer ${GITHUB_TOKEN}` } // ambient authority exercised
})
return await res.json()
}
// EXPLICIT AUTHORITY: require session-level authorization check
interface SessionAuthority {
allowedRepos: Set<string> // repos this session may read
token: string // scoped token for reads only
}
const sessionAuthority = new Map<string, SessionAuthority>()
async function listIssuesSafe(
sessionId: string,
args: { owner: string; repo: string }
): Promise<object> {
const auth = sessionAuthority.get(sessionId)
if (!auth) throw new McpError(ErrorCode.InvalidRequest, 'Session has no authority object')
const repoKey = `${args.owner}/${args.repo}`
if (!auth.allowedRepos.has(repoKey)) {
throw new McpError(
ErrorCode.InvalidRequest,
`Session is not authorized to read ${repoKey}. Allowed: ${[...auth.allowedRepos].join(', ')}`
)
}
// Uses the session's scoped token, not an ambient module-level token
const res = await fetch(`https://api.github.com/repos/${repoKey}/issues`, {
headers: { Authorization: `Bearer ${auth.token}` }
})
return await res.json()
}
2. Scoped capability objects
A capability object is a first-class value that encodes what operations are permitted, rather than providing raw API access. Instead of passing a bare API key to tool handlers, construct a capability object at session initialization time that encodes the operations the session is authorized to perform. Tool handlers receive the capability object, not the key — and the object enforces its own scope limits.
// Capability object — encodes authority, enforces scope
class GitHubReadCapability {
private readonly token: string
private readonly allowedRepos: ReadonlySet<string>
private readonly allowedOperations: ReadonlySet<string>
constructor(token: string, repos: string[], ops: string[]) {
this.token = token
this.allowedRepos = new Set(repos)
this.allowedOperations = new Set(ops)
}
async readIssues(owner: string, repo: string): Promise<object[]> {
this.assertAllowed(owner, repo, 'issues:read')
return this.githubGet(`/repos/${owner}/${repo}/issues`)
}
async readPulls(owner: string, repo: string): Promise<object[]> {
this.assertAllowed(owner, repo, 'pulls:read')
return this.githubGet(`/repos/${owner}/${repo}/pulls`)
}
// No createIssue, deleteBranch, updateFile — those operations don't exist on this object
private assertAllowed(owner: string, repo: string, op: string): void {
const key = `${owner}/${repo}`
if (!this.allowedRepos.has(key) && !this.allowedRepos.has('*')) {
throw new Error(`Not authorized to access ${key}`)
}
if (!this.allowedOperations.has(op)) {
throw new Error(`Operation ${op} not permitted by this capability`)
}
}
private async githubGet(path: string): Promise<object[]> {
const res = await fetch(`https://api.github.com${path}`, {
headers: { Authorization: `Bearer ${this.token}` }
})
if (!res.ok) throw new Error(`GitHub API error: ${res.status}`)
return res.json()
}
}
// At session creation: build a scoped capability, not a raw token reference
function buildSessionCapability(config: SessionConfig): GitHubReadCapability {
return new GitHubReadCapability(
config.githubToken,
config.allowedRepos,
['issues:read', 'pulls:read'] // read-only ops only
)
}
// Tool handlers receive the capability — they cannot exceed its scope
async function listIssuesHandler(
cap: GitHubReadCapability,
args: { owner: string; repo: string }
) {
return cap.readIssues(args.owner, args.repo) // enforced by capability
}
3. Confused deputy prevention
The confused deputy problem: a trusted intermediary (the MCP server) is tricked into using its authority on behalf of an unauthorized caller. In MCP, the confused deputy pattern occurs when an LLM-driven tool call asks the server to act on a resource the server's credentials can reach but the current session should not be allowed to access. The server acts, the server's credentials succeed, and the unauthorized access completes — because the authorization check was at the server's ambient credential level, not at the session level.
// CONFUSED DEPUTY VULNERABILITY
// The server's admin token can access ANY database table
// An LLM (or prompt injection) can request ANY table through the tool
async function readDatabaseTableVulnerable(args: { table: string; limit: number }) {
// No check that this session is authorized to read `args.table`
const rows = await db.query(
`SELECT * FROM ${args.table} LIMIT $1`, // also SQL injection
[args.limit]
)
return rows
}
// CONFUSED DEPUTY PREVENTION
const ALLOWED_TABLES: Record<string, string[]> = {
'developer': ['issues', 'pull_requests', 'commits'],
'analyst': ['metrics', 'events', 'aggregates'],
'support': ['tickets', 'users_public'],
}
// Separate the resource AUTHORIZATION check from the capability CHECK
async function readDatabaseTableSafe(
sessionRole: string,
args: { table: string; limit: number }
) {
const allowedForRole = ALLOWED_TABLES[sessionRole] ?? []
if (!allowedForRole.includes(args.table)) {
throw new McpError(
ErrorCode.InvalidRequest,
`Role '${sessionRole}' is not authorized to read table '${args.table}'. ` +
`Allowed tables: ${allowedForRole.join(', ')}`
)
}
// Parameterized + allowlisted table name
const safeTable = allowedForRole.find(t => t === args.table)!
const rows = await db.query(`SELECT * FROM ${safeTable} LIMIT $1`, [
Math.min(args.limit, 100)
])
return rows
}
4. Authority attenuation
Authority attenuation is the practice of reducing the authority of a credential before passing it to a function that needs a narrower scope. An MCP server may be configured with a broad credential at startup — an admin API key, a full-access OAuth token — but individual tool handlers should receive attenuated sub-credentials limited to the minimum necessary for their specific operation. This limits the blast radius if a handler is compromised by a prompt injection attack.
// Authority attenuation via OAuth token exchange
// The server holds a broad admin token at startup
// Each tool call exchanges it for a narrowly-scoped token
interface AttenuatedToken {
value: string
scopes: string[]
expiresAt: number
boundToSession: string
}
const attenuatedTokenCache = new Map<string, AttenuatedToken>()
async function getAttenuatedToken(
sessionId: string,
requiredScopes: string[]
): Promise<AttenuatedToken> {
const cacheKey = `${sessionId}:${requiredScopes.sort().join(',')}`
const cached = attenuatedTokenCache.get(cacheKey)
if (cached && cached.expiresAt > Date.now() + 60_000) return cached
// Exchange the admin token for a scoped token via the OAuth token exchange endpoint
const response = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: process.env.ADMIN_TOKEN,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
scope: requiredScopes.join(' '),
// Bind token to this specific session
actor_token: sessionId,
})
})
const token: AttenuatedToken = {
value: (await response.json()).access_token,
scopes: requiredScopes,
expiresAt: Date.now() + 3_600_000,
boundToSession: sessionId
}
attenuatedTokenCache.set(cacheKey, token)
return token
}
// Tool handler uses attenuated token, not the module-level admin token
async function createIssueHandler(sessionId: string, args: { repo: string; title: string }) {
const token = await getAttenuatedToken(sessionId, ['repo:issues:write'])
const res = await fetch(`https://api.github.com/repos/${args.repo}/issues`, {
method: 'POST',
headers: { Authorization: `Bearer ${token.value}` },
body: JSON.stringify({ title: args.title })
})
return res.json()
}
5. Call-site permission logging
Ambient authority is invisible by design — the credential is always there, so every call looks the same in the code. Making authority visible requires explicit logging at every call site where ambient credentials are exercised. This creates an audit trail that enables incident investigation and anomaly detection. Without it, the only evidence of an ambient authority exploitation is in external system logs (GitHub API audit log, database query logs) that may not be correlated with MCP session identifiers.
// Authority exercise logger
interface AuthorityExercise {
sessionId: string
toolName: string
resource: string // e.g. "github:owner/repo/issues"
operation: string // e.g. "read", "write", "delete"
credentialRef: string // e.g. "GITHUB_TOKEN (read-only)" — never the actual token
timestamp: string
args: Record<string, unknown> // tool arguments, sanitized
}
function logAuthorityExercise(exercise: AuthorityExercise): void {
// Write to structured audit log — separate from application log
auditLogger.info('authority_exercise', {
...exercise,
// Ensure no actual credential values appear in the log
credentialRef: exercise.credentialRef.replace(/[A-Za-z0-9+/=]{20,}/g, '[REDACTED]')
})
}
// Wrap every external API call with authority logging
async function githubFetch(
sessionId: string,
toolName: string,
operation: string,
resource: string,
url: string,
options: RequestInit = {}
): Promise<Response> {
logAuthorityExercise({
sessionId,
toolName,
resource,
operation,
credentialRef: 'GITHUB_TOKEN (scoped:repo-read)',
timestamp: new Date().toISOString(),
args: { url }
})
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
}
})
}
SkillAudit checks for ambient authority
- Module-level credentials in tool handlers:
process.env.TOKENaccessed directly insideserver.setRequestHandlercallbacks without per-call authorization check - No session-level resource authorization: Tool arguments name specific resources (repos, tables, files) without cross-referencing against session-level allowlists
- Raw credential passed to helper functions: API key or token string passed as argument to functions that need only a subset of its permissions
- Missing authority exercise logging: External API calls with credentials inside tool handlers without structured audit log entries
- Confused deputy pattern: Tool can access resources beyond what the session identity is authorized for, relying solely on the server's ambient credential level
— SkillAudit scans for these patterns automatically. Scan your MCP server.