NestJS · TypeScript · AI agents · API key management

NestJS AI agent API key management: vault keys via interceptors and dependency injection

NestJS is a common TypeScript framework for enterprise AI agent tool backends: controllers decorated with @Post() receive tool call arguments from OpenAI Agents SDK or LangChain.js agents, inject services that call Stripe or Twilio via configService.get('STRIPE_KEY'), and return structured DTOs. The problem is structural: ConfigService returns the same vendor API key to every concurrent request. Every agent session shares the same credential — no per-session spend cap, no per-request endpoint scope, no way to revoke one runaway agent without rotating the key for all active users. Vault keys fix this through NestJS's interceptor pattern and REQUEST-scoped providers, integrating per-request credential scoping without changing controller logic or service interfaces.

TL;DR

Create a VaultKeyInterceptor that implements NestInterceptor. In intercept(), issue a vault key before calling next.handle(), attach it to the request object via ExecutionContext, and pipe the observable through a finalize() operator that revokes the vault key after the response stream completes. Register the interceptor globally or per-controller. Services read the vault key from the request object using a REQUEST-scoped VaultKeyService injected via @Inject(REQUEST).

The NestJS AI agent tool backend pattern

A typical NestJS AI agent tool backend looks like this:

import { Controller, Post, Body, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Stripe from "stripe";

@Injectable()
export class StripeService {
  private stripe: Stripe;

  constructor(private config: ConfigService) {
    // Initialized once per application lifecycle — shared across ALL requests
    this.stripe = new Stripe(config.get("STRIPE_KEY"));
  }

  async createIntent(amount: number, customerId: string) {
    return this.stripe.paymentIntents.create({
      amount,
      currency: "usd",
      customer: customerId,
    });
  }
}

@Controller("tools")
export class AgentToolController {
  constructor(private stripeService: StripeService) {}

  @Post("charge")
  async charge(@Body() body: ChargeDto) {
    return this.stripeService.createIntent(body.amount, body.customerId);
  }
}

This is idiomatic NestJS with dependency injection. The problem: StripeService is a singleton by default — one instance per application, initialized once, shared across all requests. Every concurrent agent call uses the same stripe instance with the same API key. Spend by one user's agent is indistinguishable from spend by another's at the Stripe level.

Adding vault keys via NestJS interceptors

NestJS interceptors wrap the entire request lifecycle and have access to ExecutionContext and the response observable — the right place to issue vault keys before the controller and revoke them after:

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from "@nestjs/common";
import { Observable } from "rxjs";
import { finalize } from "rxjs/operators";
import fetch from "node-fetch";

@Injectable()
export class VaultKeyInterceptor implements NestInterceptor {
  async intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Promise<Observable<any>> {
    const request = context.switchToHttp().getRequest();

    // Skip non-agent routes
    if (!request.path.startsWith("/tools/")) {
      return next.handle();
    }

    const userId = request.headers["x-user-id"] ?? "anonymous";
    const runId = request.headers["x-agent-run-id"] ?? "unknown";

    let vaultKeyId: string | null = null;

    try {
      const resp = await fetch("https://api.keybrake.com/v1/keys", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.KEYBRAKE_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          label: `nestjs-${userId}-${runId}`,
          vendor: "stripe",
          allowed_endpoints: ["/v1/payment_intents", "/v1/payment_intents/*"],
          daily_usd_cap: 500,
          expires_in: "5m",
        }),
      });

      if (!resp.ok) throw new Error(`Keybrake: ${resp.status}`);
      const keyData: any = await resp.json();
      request.vaultKey = keyData.token;
      vaultKeyId = keyData.id;
    } catch (err) {
      console.error("Vault key issuance failed:", err);
      request.vaultKey = null;
    }

    return next.handle().pipe(
      finalize(async () => {
        if (vaultKeyId) {
          try {
            await fetch(`https://api.keybrake.com/v1/keys/${vaultKeyId}`, {
              method: "DELETE",
              headers: {
                Authorization: `Bearer ${process.env.KEYBRAKE_TOKEN}`,
              },
            });
          } catch (_) {
            // TTL is the safety net — revocation failure is non-critical
          }
        }
      }),
    );
  }
}

Register the interceptor globally in your app module, or per-controller using @UseInterceptors(VaultKeyInterceptor):

// Option 1: global (applies to all routes)
import { APP_INTERCEPTOR } from "@nestjs/core";

@Module({
  providers: [
    { provide: APP_INTERCEPTOR, useClass: VaultKeyInterceptor },
  ],
})
export class AppModule {}

// Option 2: per-controller
@Controller("tools")
@UseInterceptors(VaultKeyInterceptor)
export class AgentToolController { ... }

Accessing the vault key in services via REQUEST scope

NestJS's default singleton scope means services can't access the per-request vault key directly. Use REQUEST-scoped providers to inject the vault key into services that need it:

import { Injectable, Scope, Inject } from "@nestjs/common";
import { REQUEST } from "@nestjs/core";
import { Request } from "express";
import fetch from "node-fetch";

@Injectable({ scope: Scope.REQUEST })
export class ProxyStripeService {
  constructor(@Inject(REQUEST) private request: Request) {}

  async createIntent(amount: number, customerId: string) {
    const vaultKey = (this.request as any).vaultKey;
    if (!vaultKey) throw new Error("No vault key available for this request");

    const resp = await fetch(
      "https://proxy.keybrake.com/stripe/v1/payment_intents",
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${vaultKey}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          amount,
          currency: "usd",
          customer: customerId,
        }),
      },
    );

    if (resp.status === 429) {
      const body: any = await resp.json();
      if (body.code === "cap_exhausted") {
        throw new Error("SPEND_CAP_EXCEEDED");
      }
    }

    if (!resp.ok) throw new Error(`Stripe proxy: ${resp.status}`);
    return resp.json();
  }
}

// Controller with REQUEST-scoped service injection
@Controller("tools")
@UseInterceptors(VaultKeyInterceptor)
export class AgentToolController {
  constructor(private proxyStripeService: ProxyStripeService) {}

  @Post("charge")
  async charge(@Body() body: ChargeDto) {
    try {
      return await this.proxyStripeService.createIntent(
        body.amount,
        body.customerId,
      );
    } catch (err) {
      if (err.message === "SPEND_CAP_EXCEEDED") {
        throw new HttpException("spend_cap_exceeded", HttpStatus.PAYMENT_REQUIRED);
      }
      throw err;
    }
  }
}

REQUEST-scoped providers cascade: any singleton that injects a REQUEST-scoped provider automatically becomes REQUEST-scoped. Plan your provider scope graph before using REQUEST scope in deeply nested service trees — the NestJS docs cover the cascading rules in the injection context section.

Performance and DI scope considerations

ConcernImpactMitigation
Interceptor latency ~30–80ms per agent request (async HTTPS to api.keybrake.com) NestJS's async interceptor model handles this non-blocking using Node's event loop. For high-throughput endpoints, pre-issue session-scoped vault keys with 15-minute TTLs and cache in Redis
REQUEST scope performance cost NestJS instantiates a new provider instance per request for REQUEST-scoped providers — slightly more GC pressure than singletons For agent tool backends this is acceptable — the vault key issuance round-trip (~50ms) dominates over DI instantiation (~1ms). Only relevant at very high request rates (1,000+ RPS)
finalize() and SSE / WebSocket finalize() fires when the observable completes — which for SSE streams happens when the connection closes, not after each event For SSE or WebSocket agent endpoints, issue vault keys at connection open with a longer TTL (matching max session duration) and revoke explicitly on connection close via @SubscribeMessage disconnect handler
Singleton services and REQUEST scope You can't inject a REQUEST-scoped service into a singleton — NestJS throws a DI error at startup Keep the original StripeService as a singleton for non-agent routes. Create ProxyStripeService as REQUEST-scoped for agent tool routes. Use module separation to control which routes use which service.

Get early access

Related questions

Can vault keys work with NestJS GraphQL resolvers?

Yes. NestJS GraphQL uses the same underlying HTTP request object, so context.switchToHttp().getRequest() in the interceptor returns the same request that resolvers access via @Context(). Register VaultKeyInterceptor globally or on specific resolvers using @UseInterceptors(VaultKeyInterceptor). In the resolver, access the vault key from the GraphQL context: @Context() ctx: { req: Request } gives you ctx.req.vaultKey. For DataLoader patterns that fan out resolver calls across a single request, the same vault key is shared — this is correct behavior since all DataLoader calls belong to the same agent request and should share a single spend cap.

How do vault keys work with NestJS microservices (TCP, RabbitMQ, Kafka)?

NestJS microservice transports (TCP, RabbitMQ, Kafka) don't use HTTP request/response — they use message patterns. The interceptor pattern still applies: context.switchToRpc() gives you the RPC context instead of HTTP context. However, vault key issuance should happen at the message handler level rather than as a global HTTP interceptor, since the "request" concept maps to a message, not an HTTP request. Issue vault keys at the start of the message handler and revoke them in a finally block inside the handler, not in a finalize() pipe on the observable. This is the same pattern as Celery tasks — one vault key per unit of autonomous work, regardless of transport.

What's the recommended NestJS module structure for vault key management?

Create a VaultModule that exports VaultKeyInterceptor and ProxyStripeService. Import VaultModule into the modules that contain agent tool controllers — not globally in AppModule, unless every endpoint in your app calls vendor APIs. This keeps the REQUEST scope cascading contained to the agent tool module rather than contaminating the entire application's DI graph. Add ConfigModule to VaultModule's imports so the interceptor can read KEYBRAKE_TOKEN from ConfigService rather than directly from process.env — this keeps the vault module testable and consistent with NestJS config patterns.

Further reading