Topic: dependency injection security

MCP server dependency injection security — singleton vs scoped services, ambient credential injection, DI container security, service lifetime vulnerabilities

Dependency injection containers in MCP servers create non-obvious security risks when service lifetimes are misconfigured. A singleton that captures credentials at startup cannot rotate them. A scoped service that leaks between requests creates cross-session data exposure. A DI container that can be modified by plugin or handler code creates a service override attack surface. Five patterns: lifetime-aware credential injection, request-scoped service isolation, container scope enforcement, ambient authority prevention, and DI container hardening.

1. Lifetime-aware credential injection

The most critical service lifetime error in MCP server DI is injecting a credential value — a string token, a client instance initialized with a credential — into a singleton service. The singleton is created once at bootstrap and lives for the entire process lifetime. The credential it holds cannot be rotated without restarting the server. In a long-running MCP server deployment, this means credentials may run for months without rotation, increasing the impact window of any credential compromise.

The correct pattern is to inject a CredentialProvider interface — not a credential value — into services that need credentials. The provider fetches the current credential value when called, allowing the underlying secret to be rotated in the secret manager and picked up by the server without a restart. Services that call external APIs create their clients per-request using the freshly-fetched credential, not a stale value from bootstrap time.

// ANTI-PATTERN: Singleton with captured credential — cannot rotate without restart
class GitHubServiceSingleton {
  private readonly client: GitHubClient

  constructor() {
    // Credential captured at construction — frozen for the process lifetime
    this.client = new GitHubClient(process.env.GITHUB_TOKEN!)
  }

  async listRepos(org: string) { return this.client.listRepos(org) }
}

// CORRECT PATTERN: CredentialProvider interface — resolved per-call
interface CredentialProvider {
  getCredential(name: string): Promise
}

class SecretsManagerCredentialProvider implements CredentialProvider {
  private cache = new Map()
  private readonly TTL_MS = 5 * 60 * 1000  // 5-minute cache TTL

  async getCredential(name: string): Promise {
    const cached = this.cache.get(name)
    if (cached && cached.expiresAt > Date.now()) return cached.value

    // Fetch fresh credential from secret manager
    const value = await fetchFromSecretsManager(name)
    this.cache.set(name, { value, expiresAt: Date.now() + this.TTL_MS })
    return value
  }
}

// Service uses CredentialProvider — can be a singleton, credentials rotate transparently
class GitHubService {
  constructor(private credentials: CredentialProvider) {}

  async listRepos(org: string): Promise {
    // Fresh credential fetched per call — picks up rotation within TTL
    const token = await this.credentials.getCredential('github-token')
    const client = new GitHubClient(token)
    return client.listRepos(org)
  }
}

// DI container registration — service is singleton, credential provider handles rotation
import 'reflect-metadata'
import { Container, injectable, inject } from 'inversify'

const container = new Container()
container.bind(CredentialProvider).to(SecretsManagerCredentialProvider).inSingletonScope()
container.bind(GitHubService).to(GitHubService).inSingletonScope()
// GitHubService is singleton but uses CredentialProvider — rotation works correctly

2. Request-scoped service isolation

In an MCP server handling concurrent requests, services that accumulate per-request state must not be registered as singletons — each request needs a fresh, isolated instance. A service that collects the tool calls made during a request, builds an authorization context from the request's session, or caches resources fetched during a single operation must be scoped to the request lifetime and destroyed when the response is sent.

Request scoping is implemented by creating a child DI container for each incoming MCP request, resolving all request-scoped services within that child container, and disposing of the child container when the request handler completes. Services resolved in the child container cannot access services resolved in sibling child containers, preventing cross-request state leakage.

import { Container } from 'inversify'

// Request context — accumulates per-request state, must not be shared across requests
@injectable()
class RequestContext {
  readonly requestId = crypto.randomUUID()
  readonly startedAt = Date.now()
  private toolCallLog: Array<{ tool: string; args: unknown; calledAt: number }> = []
  private resourceCache = new Map()

  logToolCall(tool: string, args: unknown): void {
    this.toolCallLog.push({ tool, args, calledAt: Date.now() })
  }

  cacheResource(key: string, value: unknown): void {
    this.resourceCache.set(key, value)
  }

  getCachedResource(key: string): unknown {
    return this.resourceCache.get(key)
  }

  toAuditEntry() {
    return {
      requestId: this.requestId,
      durationMs: Date.now() - this.startedAt,
      toolCalls: this.toolCallLog
    }
  }
}

// Root container — singletons only (stateless services, credential providers)
const rootContainer = new Container()
rootContainer.bind(CredentialProvider).to(SecretsManagerCredentialProvider).inSingletonScope()
rootContainer.bind(GitHubService).to(GitHubService).inSingletonScope()

// Lock root container after bootstrap — no new registrations from request handlers
Object.freeze(rootContainer)

// MCP request handler — creates per-request scope
server.setRequestHandler(CallToolRequestSchema, async (request, context) => {
  // Create isolated child container for this request
  const requestContainer = rootContainer.createChild()

  // Register request-scoped services
  requestContainer.bind(RequestContext).toSelf().inSingletonScope()
  // In the child container, 'singleton' means 'one per request scope'

  try {
    const requestCtx = requestContainer.get(RequestContext)
    requestCtx.logToolCall(request.params.name, request.params.arguments)

    const result = await handleToolCall(
      request.params.name,
      request.params.arguments,
      requestContainer
    )

    // Audit log for this request
    auditLog.info('request_completed', requestCtx.toAuditEntry())
    return result
  } finally {
    // Destroy child container — all request-scoped services disposed
    requestContainer.unbindAll()
  }
})

3. Container scope enforcement

A DI container that can accept new service registrations at runtime — from plugin code, from request handlers, or from any code that holds a reference to the container — is vulnerable to service override attacks. An attacker who can register a new binding for a trusted interface (such as a credential provider or authorization service) can replace the legitimate implementation with one that exfiltrates credentials or bypasses authorization checks. The container must be locked after the bootstrap phase.

import { Container, interfaces } from 'inversify'

class LockedContainer {
  private container: Container
  private locked = false
  private readonly registeredBindings = new Set()

  constructor() {
    this.container = new Container()
  }

  bind(serviceIdentifier: interfaces.ServiceIdentifier) {
    if (this.locked) {
      throw new Error(
        `DI container is locked. Cannot register new binding for '${String(serviceIdentifier)}' ` +
        `after server bootstrap. This may indicate a plugin or handler attempting ` +
        `to override a trusted service.`
      )
    }
    if (this.registeredBindings.has(serviceIdentifier as symbol | string)) {
      throw new Error(
        `Duplicate binding for '${String(serviceIdentifier)}'. ` +
        `Service override is not permitted.`
      )
    }
    this.registeredBindings.add(serviceIdentifier as symbol | string)
    return this.container.bind(serviceIdentifier)
  }

  get(serviceIdentifier: interfaces.ServiceIdentifier): T {
    return this.container.get(serviceIdentifier)
  }

  createChild(): Container {
    if (!this.locked) {
      throw new Error('Cannot create child container before locking the root container')
    }
    return this.container.createChild()
  }

  lock(): void {
    if (this.locked) throw new Error('Container already locked')
    this.locked = true
    auditLog.info('container_locked', {
      registeredServices: this.registeredBindings.size,
      services: Array.from(this.registeredBindings).map(String)
    })
  }
}

// Bootstrap sequence
const container = new LockedContainer()

// Register all services during bootstrap
container.bind(CredentialProvider).to(SecretsManagerCredentialProvider).inSingletonScope()
container.bind(GitHubService).to(GitHubService).inSingletonScope()
container.bind(AuthorizationService).to(AuthorizationService).inSingletonScope()

// Lock before starting request handling — no further registrations allowed
container.lock()

// Plugins receive a capability object, not a container reference
// They cannot call container.bind() because they don't have a container reference

4. Ambient authority prevention

"Ambient authority" in software security refers to code that obtains capabilities through global state rather than through explicit parameter passing. In a DI context, ambient authority occurs when handler or service code calls container.resolve(SomeService) globally rather than receiving the service as a constructor parameter. Code with ambient container access can resolve any service registered in the container — including services the calling code was not explicitly granted. It also bypasses request scope boundaries, because global container resolution returns the singleton instance rather than the request-scoped instance.

// ANTI-PATTERN: Ambient container access — bypasses scope and access control
import { globalContainer } from './container'  // exported global reference

class ToolHandler {
  async handle(args: unknown) {
    // Resolves from global container — bypasses request scope,
    // gets singleton instead of request-scoped service,
    // can resolve ANY registered service regardless of what was explicitly granted
    const githubService = globalContainer.resolve(GitHubService)
    const authService = globalContainer.resolve(AuthorizationService)
    // ...
  }
}

// CORRECT PATTERN: Explicit constructor injection — all dependencies visible
@injectable()
class ToolHandler {
  constructor(
    // All dependencies are explicit — reviewers can see exactly what this class can access
    @inject(GitHubService) private github: GitHubService,
    @inject(RequestContext) private ctx: RequestContext
    // AuthorizationService is NOT injected — this handler doesn't need it
    // and therefore cannot access it, even if it exists in the container
  ) {}

  async handle(args: unknown) {
    this.ctx.logToolCall('my_tool', args)
    return this.github.listRepos(args.org)
  }
}

// Static analysis rule: detect ambient container resolution
// Flag any call to container.resolve() / container.get() outside of:
// (a) the bootstrap phase, or (b) the request scope factory function
function detectAmbientContainerAccess(sourceFile: string): string[] {
  const violations: string[] = []
  const lines = sourceFile.split('\n')

  lines.forEach((line, i) => {
    // Flag: container.get() or container.resolve() called in non-bootstrap code
    if (/(?:container|Container)\.(?:get|resolve)\(/.test(line)) {
      if (!line.includes('// bootstrap') && !line.includes('// scope-factory')) {
        violations.push(`Line ${i + 1}: Potential ambient container access: ${line.trim()}`)
      }
    }
  })

  return violations
}

5. DI container hardening

Beyond lifetime and scope issues, DI containers in MCP servers have several hardening requirements: string-key service identifiers are vulnerable to key collision attacks (two services registered under the same string key), singleton services with mutable state create implicit shared state across all requests, and factory functions with captured closures may hold stale credentials or references to destroyed request contexts. These require an explicit audit pass at code review time.

import { Symbol } from 'reflect-metadata'

// HARDENING PATTERN 1: Typed symbol keys — prevent string key collision attacks
// Two plugins that both register 'AuthService' as a string key create a collision
const TOKENS = {
  CredentialProvider: Symbol.for('CredentialProvider'),
  GitHubService: Symbol.for('GitHubService'),
  AuthorizationService: Symbol.for('AuthorizationService'),
  RequestContext: Symbol.for('RequestContext')
} as const

// HARDENING PATTERN 2: Immutable singleton — no mutable properties
@injectable()
class ImmutableConfig {
  // All properties are readonly — no shared mutable state across requests
  constructor(
    readonly apiBaseUrl: string,
    readonly maxConcurrentRequests: number,
    readonly allowedOutputPaths: readonly string[]
  ) {}
}

// HARDENING PATTERN 3: Audit factory functions for captured closures
function createGitHubServiceFactory(credentials: CredentialProvider) {
  // Safe: credentials is a CredentialProvider interface, not a captured credential value
  // The factory creates a new service instance with a reference to the provider
  return () => new GitHubService(credentials)
}

// DANGEROUS: Factory captures a credential value, not a provider
// function createBadFactory() {
//   const token = process.env.GITHUB_TOKEN  // captured at factory creation time
//   return () => new GitHubService(token)   // always uses the captured value
// }

// HARDENING PATTERN 4: Service resolution audit log
class AuditingContainer extends LockedContainer {
  private resolutionLog: Array<{ service: string; resolvedAt: number }> = []

  get(serviceIdentifier: interfaces.ServiceIdentifier): T {
    this.resolutionLog.push({
      service: String(serviceIdentifier),
      resolvedAt: Date.now()
    })
    return super.get(serviceIdentifier)
  }

  getResolutionLog() {
    return [...this.resolutionLog]  // return copy — log is append-only
  }
}

// Container health check — validates all registered services are resolvable
// Run at startup to catch misconfiguration before the first request
async function validateContainerHealth(container: AuditingContainer): Promise {
  const requiredServices = [
    TOKENS.CredentialProvider,
    TOKENS.GitHubService,
    TOKENS.AuthorizationService
  ]

  for (const token of requiredServices) {
    try {
      container.get(token)
    } catch (error) {
      throw new Error(
        `DI container health check FAILED for service '${String(token)}': ${error}\n` +
        `Verify that all required bindings are registered in the bootstrap phase.`
      )
    }
  }
}

SkillAudit checks for dependency injection security

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