Next.js · App Router · AI agents · API key management

Next.js AI agent API key management: vault keys via Route Handlers and middleware

Next.js is the most common full-stack framework for building AI agent tool backends. Route Handlers under app/api/ receive function-call requests from OpenAI Agents SDK, LangChain.js, or the Vercel AI SDK, read process.env.STRIPE_SECRET_KEY, call the Stripe SDK, and stream back results. The problem: process.env.STRIPE_SECRET_KEY is a single long-lived credential shared by every concurrent request from every user's agent session. There is no per-session spend cap, no endpoint scope, and no way to revoke one runaway agent without rotating the key globally. Vault keys from Keybrake solve this by issuing a short-lived, scoped token at the start of each agent request and revoking it the moment the handler returns.

TL;DR

In each Route Handler, call POST https://api.keybrake.com/v1/keys to issue a vault key at the top of the handler, then call DELETE /v1/keys/:id in a finally block after your vendor API call completes. Use the returned token as the credential when calling proxy.keybrake.com/stripe instead of api.stripe.com. For streaming responses, wrap the vault key lifecycle around the stream setup rather than the consumption — the TTL is the fallback safety net.

The standard Next.js AI agent tool pattern and its problem

A typical Next.js AI agent tool Route Handler for a Stripe charge looks like this:

// app/api/tools/charge/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const { amount, customerId } = await req.json();

  const intent = await stripe.paymentIntents.create({
    amount,
    currency: "usd",
    customer: customerId,
  });

  return NextResponse.json({ clientSecret: intent.client_secret });
}

The stripe instance is initialized at module load time — once per Next.js server process — and shared across all requests. In development, hot-reload recreates it on each change. In production on Vercel, each serverless function invocation may create a fresh instance but all share the same STRIPE_SECRET_KEY env var. Multiple concurrent agent sessions — from different users, different agent runs, or the same user's parallel tool calls — all share the same credential with no isolation between them.

Adding vault keys to a Next.js Route Handler

Replace the direct Stripe SDK call with a Keybrake vault key issued per-request:

// app/api/tools/charge/route.ts
import { NextRequest, NextResponse } from "next/server";

async function issueVaultKey(label: string) {
  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,
      vendor: "stripe",
      allowed_endpoints: ["/v1/payment_intents", "/v1/payment_intents/*"],
      daily_usd_cap: 500,
      expires_in: "5m",
    }),
  });
  if (!resp.ok) throw new Error(`Vault key issuance failed: ${resp.status}`);
  return resp.json() as Promise<{ id: string; token: string }>;
}

async function revokeVaultKey(id: string) {
  await fetch(`https://api.keybrake.com/v1/keys/${id}`, {
    method: "DELETE",
    headers: { Authorization: `Bearer ${process.env.KEYBRAKE_TOKEN}` },
  }).catch(() => {}); // TTL is the safety net
}

export async function POST(req: NextRequest) {
  const { amount, customerId } = await req.json();
  const userId = req.headers.get("x-user-id") ?? "anon";
  const runId = req.headers.get("x-agent-run-id") ?? "unknown";

  const vaultKey = await issueVaultKey(`nextjs-${userId}-${runId}`);

  try {
    const resp = await fetch(
      "https://proxy.keybrake.com/stripe/v1/payment_intents",
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${vaultKey.token}`,
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          amount: String(amount),
          currency: "usd",
          customer: customerId,
        }),
      },
    );

    if (resp.status === 429) {
      const body = await resp.json();
      if (body.code === "cap_exhausted") {
        return NextResponse.json(
          { error: "spend_cap_exceeded" },
          { status: 402 },
        );
      }
    }

    if (!resp.ok) {
      return NextResponse.json({ error: "stripe_error" }, { status: 502 });
    }

    const intent = await resp.json();
    return NextResponse.json({ clientSecret: intent.client_secret });
  } finally {
    await revokeVaultKey(vaultKey.id);
  }
}

The vault key is issued at the top of the handler, used for one Stripe call, and revoked in the finally block. If the handler throws or times out, the TTL ensures revocation. The Stripe SDK is no longer needed — the proxy accepts standard Stripe API requests over HTTP.

Vault keys with Vercel AI SDK streaming

The Vercel AI SDK's streamText function is common for streaming agent responses in Next.js. Vault keys work, but the lifecycle wraps the stream setup, not consumption:

// app/api/agent/route.ts
import { NextRequest } from "next/server";
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";

export async function POST(req: NextRequest) {
  const { messages } = await req.json();
  const userId = req.headers.get("x-user-id") ?? "anon";

  // Issue vault key before the agent stream starts
  const vaultKey = await issueVaultKey(`ai-sdk-stream-${userId}`);

  // Pass vault key to tool implementations via closure
  const result = streamText({
    model: openai("gpt-4o"),
    messages,
    tools: {
      chargeCustomer: {
        description: "Charge a customer via Stripe",
        parameters: z.object({
          amount: z.number(),
          customerId: z.string(),
        }),
        execute: async ({ amount, customerId }) => {
          // Uses the vault key from the outer closure
          const resp = await fetch(
            "https://proxy.keybrake.com/stripe/v1/payment_intents",
            {
              method: "POST",
              headers: { Authorization: `Bearer ${vaultKey.token}` },
              body: new URLSearchParams({
                amount: String(amount),
                currency: "usd",
                customer: customerId,
              }),
            },
          );
          return resp.json();
        },
      },
    },
    onFinish: async () => {
      // Revoke when the stream completes
      await revokeVaultKey(vaultKey.id);
    },
  });

  return result.toDataStreamResponse();
}

For streaming, the vault key TTL must cover the maximum expected stream duration — a 5-minute TTL covers most agent runs, but long-running multi-step agents may need 15–30 minutes. Set the TTL to the maximum expected agent session length, not to the average.

Next.js deployment contexts and vault key behavior

DeploymentConcurrency modelVault key behavior
Vercel Serverless Functions One request per function invocation; functions can run concurrently One vault key per function invocation — natural isolation. Cold starts add ~100ms to first key issuance; subsequent calls are warm
Vercel Edge Runtime V8 isolate per request; fetch available, Node.js APIs not Vault key issuance uses fetch — fully compatible with Edge Runtime. Cannot use Node.js https module. TTL-based revocation recommended since Edge functions have shorter timeouts
Next.js on Node.js (self-hosted) Single Node.js process; async concurrent requests share event loop One vault key per request via async/await — each concurrent handler issues its own key independently. Module-level stripe instances are shared but vault keys are not
Next.js Docker / Railway / Fly.io One or more Node.js processes behind a load balancer Same as self-hosted Node.js. Vault keys are per-request, not per-process — no coordination needed across replicas

Shared helper for all Route Handlers

Extract vault key management into a shared utility to avoid repeating it in every Route Handler:

// lib/vault.ts
export interface VaultKey {
  id: string;
  token: string;
}

export async function withVaultKey<T>(
  label: string,
  vendor: string,
  options: {
    allowed_endpoints?: string[];
    daily_usd_cap?: number;
    expires_in?: string;
  },
  fn: (token: string) => Promise<T>,
): Promise<T> {
  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, vendor, ...options }),
  });

  if (!resp.ok) throw new Error(`Vault key issuance failed: ${resp.status}`);
  const key: VaultKey = await resp.json();

  try {
    return await fn(key.token);
  } finally {
    fetch(`https://api.keybrake.com/v1/keys/${key.id}`, {
      method: "DELETE",
      headers: { Authorization: `Bearer ${process.env.KEYBRAKE_TOKEN}` },
    }).catch(() => {});
  }
}

// Usage in a Route Handler:
export async function POST(req: NextRequest) {
  const { amount, customerId } = await req.json();

  return withVaultKey(
    "charge-tool",
    "stripe",
    { allowed_endpoints: ["/v1/payment_intents"], daily_usd_cap: 500 },
    async (token) => {
      const resp = await fetch(
        "https://proxy.keybrake.com/stripe/v1/payment_intents",
        { method: "POST", headers: { Authorization: `Bearer ${token}` }, ... },
      );
      return NextResponse.json(await resp.json());
    },
  );
}

Get early access

Related questions

Can I use vault keys with Next.js Server Actions?

Yes. Server Actions are async functions that run server-side — they have access to environment variables and can make outbound fetch calls. Issue the vault key at the top of the Server Action, use it in the vendor API call, and revoke in a finally block, exactly like a Route Handler. The main difference is that Server Actions are called from client components, so they don't have a NextRequest object — pass user identification via function arguments rather than headers. For Server Actions triggered from form submissions, issue the vault key inside the action function, not in middleware, since Server Actions bypass the middleware layer for some Next.js features.

Should I use Next.js middleware to issue vault keys for all API routes?

Next.js middleware runs before every matched route and has access to the request — but it cannot pass data to Route Handlers via request mutation in the App Router (the middleware and Route Handler run in separate contexts). The recommended pattern is per-Route-Handler issuance (shown above) or a shared withVaultKey helper. Middleware is better for token validation, authentication checks, and logging — not for issuing per-request vault keys that need to be revoked after the response. The exception: if you need to enforce a rate limit or budget check before expensive processing begins, middleware can call the Keybrake validation endpoint without issuing a full vault key.

How do vault keys work with Next.js API route caching?

Next.js App Router has aggressive fetch caching that can cache the results of fetch calls inside Route Handlers. Vault key issuance and revocation calls should use cache: 'no-store' to prevent the Keybrake API responses from being cached: fetch('https://api.keybrake.com/v1/keys', { cache: 'no-store', ... }). Similarly, vendor API proxy calls should be uncached since they perform real transactions. The export const dynamic = 'force-dynamic' directive on a Route Handler file opts the entire route out of static optimization — use this for any Route Handler that issues vault keys or calls transactional APIs.

What happens if the Keybrake API is unavailable when issuing a vault key?

Decide your failure mode at design time, not at incident time. Fail-closed (default): if vault key issuance fails, return a 503 to the agent caller and don't make the vendor API call. This is safest for production financial transactions — no vault key means no untracked spend. Fail-open: if vault key issuance fails, fall back to the direct Stripe API key. This keeps the agent running but loses spend isolation. Implement fail-open by catching the issuance error and falling back to process.env.STRIPE_SECRET_KEY with a warning log. Recommended: fail-closed for Stripe and Twilio (where untracked spend is costly), fail-open for Resend (where a missed email is worse than an untracked send).

Further reading