React / Next.js App Router

Secure Your Stripe API Key in React

Calling Stripe's API directly from React client code exposes your sk_live_ key in the browser — anyone with DevTools can extract it and issue charges or refunds on your account. The publishable key (pk_live_) belongs in the browser; the secret key never does.

The Problem

This is what most React codebases look like today. The STRIPE_SECRET_KEY key (sk_live_...) lives directly in the process environment. If your repo, your CI logs, or your deployment config leaks — so does your key.

TypeScript
// ❌ DANGER: Stripe SECRET key in browser JavaScript
// components/CheckoutButton.tsx
import { useState } from "react";

// This puts sk_live_ in your client bundle — extractable by anyone
const STRIPE_SECRET = import.meta.env.VITE_STRIPE_SECRET_KEY; // sk_live_...

export function CheckoutButton({ priceId }: { priceId: string }) {
  const [loading, setLoading] = useState(false);

  async function handleCheckout() {
    setLoading(true);
    // Direct Stripe API call from the browser with the secret key
    const res = await fetch("https://api.stripe.com/v1/checkout/sessions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${STRIPE_SECRET}`, // exposed!
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({ mode: "payment", "line_items[0][price]": priceId }),
    });
    const session = await res.json();
    window.location.href = session.url;
  }

  return <button onClick={handleCheckout}>{loading ? "..." : "Buy Now"}</button>;
}

The Fix

Send the request to the KeyVault Edge proxy at https://proxy.keyvaultedge.com with the provider's usual path, your kvt_ token in X-KV-Token and the provider's base URL in X-KV-Target. The proxy refuses requests whose Origin isn't on the token's allowed hosts, then injects your real STRIPE_SECRET_KEY, so it never sits in your app's environment.

TypeScript
// ✅ SAFE: Secret key stays on the server; browser only gets the session URL

// app/api/checkout/route.ts (server-side — no browser access)
export async function POST(req: Request) {
  const { priceId } = await req.json();

  const response = await fetch("https://proxy.keyvaultedge.com/v1/checkout/sessions", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded", // Stripe uses form bodies
      "X-KV-Token": process.env.KVE_TOKEN!, // kvt_... token, server-side only
      "X-KV-Target": "https://api.stripe.com",
      // Must match one of the token's allowed hosts
      Origin: process.env.APP_ORIGIN!,
    },
    body: new URLSearchParams({
      mode: "payment",
      "line_items[0][price]": priceId,
      "line_items[0][quantity]": "1",
      success_url: "https://myapp.com/success",
      cancel_url: "https://myapp.com/pricing",
    }),
  });

  const data = await response.json();
  return Response.json({ url: data.url });
}

// components/CheckoutButton.tsx (React client — no keys here)
"use client";
import { useState } from "react";

export function CheckoutButton({ priceId }: { priceId: string }) {
  const [loading, setLoading] = useState(false);

  async function handleCheckout() {
    setLoading(true);
    const res = await fetch("/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ priceId }),
    });
    const { url } = await res.json();
    window.location.href = url;
  }

  return <button onClick={handleCheckout}>{loading ? "..." : "Buy Now"}</button>;
}

Setup in 4 steps

1

Move all Stripe API calls to a server route

Create app/api/checkout/route.ts (Next.js App Router). This file is server-only — Stripe's secret key never appears in any browser-accessible file.

2

Create a KVE token and add it server-side

At keyvaultedge.com, create a token for your sk_live_ key. Add KVE_TOKEN to your server environment — do not prefix it with NEXT_PUBLIC_.

Shell
# .env.local
KVE_TOKEN=kvt_...your_token
APP_ORIGIN=https://yourdomain.com  # one of the token's allowed hosts
# Publishable key is intentionally public:
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
3

Update your React component to call your own API route

The CheckoutButton component calls /api/checkout (your own server) with fetch. The browser never talks directly to Stripe's API with a secret key — only the session URL flows back to the client.

4

Verify with the Stripe dashboard

In your Stripe dashboard under Developers → Logs, confirm that all API calls originate from your server IP, not from browser user-agents. Any browser-origin calls indicate a key exposure.

What this protects, and what it doesn't

  • Your real STRIPE_SECRET_KEY stays at the edge, so it can't leak from your repo, logs, bundle or server environment.
  • The kvt_ token is still a credential. A copied token can be replayed by anyone who can send the right headers, including a fake Origin from a server. The protection is that each token has its own rate limits, monthly cost cap and endpoint scope, and you can revoke it in one click without rotating the provider key.
  • The cost cap is estimated from the usage the provider reports, so a request already in flight can overshoot it slightly.

Plans and limits are on the pricing page.

Start protecting your keys

Your Stripe key is one leaked .env file away from being exploited. With KeyVault Edge the real key stays at the edge, and the token that replaces it is capped, scoped and revocable in one click.