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

SkillAudit scans for these patterns automatically. Scan your MCP server.