vibeblame

Как устранить утечки API-ключей в JS-бандлах

API-ключи во фронтенд-бандле видны всем. Правильный способ работы с секретами в Next.js, Astro, Vue (Nuxt), Angular, WordPress и PHP.

Почему секреты во фронтенде всегда видны

Клиентский JavaScript публичен. Всё, что попало в JS-бандл, можно прочитать через DevTools -> Sources или скачав файл напрямую. Способа спрятать секрет на клиенте не существует.

Типичные ошибки: передать секретный ключ в React-компонент, использовать NEXT_PUBLIC_STRIPE_SECRET_KEY думая «это же просто переменная», вызывать сторонний API прямо из браузера с ключом, дающим права на запись.

Правило: если ключ даёт доступ к платежам, записи данных или пользовательской информации — он не должен попадать в браузер.


Найдите утечки в своём бандле

npm run build

# Next.js
grep -r "sk_live\|AIza\|ghp_\|xoxb-\|NEXT_PUBLIC_" .next/static/

# Vite / CRA / Astro
grep -r "sk_live\|AIza\|ghp_\|xoxb-" dist/

# Nuxt
grep -r "sk_live\|AIza\|ghp_\|xoxb-" .output/public/

# Angular
grep -r "sk_live\|AIza\|ghp_\|xoxb-" dist/

Next.js

// Неправильно — секретный ключ попадёт в бандл
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!)

// Правильно — только на сервере
// app/api/checkout/route.ts
export async function POST(req: Request) {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) // без NEXT_PUBLIC_
}

Server Actions (Next.js 14+):

// app/actions.ts
'use server'

export async function createCheckoutSession(priceId: string) {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
  const session = await stripe.checkout.sessions.create({ ... })
  return session.url
}

Правила переменных окружения в Next.js:

ПрефиксГде доступнаВидна в бандле
NEXT_PUBLIC_Браузер + СерверДа
(без префикса)Только серверНет

NEXT_PUBLIC_ используйте только для значений, которым публичность не вредит: аналитические ID, Stripe publishable key, API-ключи с ограничениями по домену.


Astro

Astro использует тот же паттерн — префикс PUBLIC_ делает переменную доступной в браузере:

// Неправильно — попадёт в браузер
const key = import.meta.env.PUBLIC_STRIPE_SECRET_KEY

// Правильно — только на сервере
// src/pages/api/checkout.ts
export async function POST({ request }) {
  const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY) // без PUBLIC_
}
ПрефиксГде доступнаВидна в бандле
PUBLIC_Браузер + СерверДа
(без префикса)Только серверНет

Vue (Nuxt)

Nuxt 3 использует runtimeConfig для разделения публичных и приватных переменных окружения:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Только сервер — в браузер не попадает
    stripeSecretKey: process.env.STRIPE_SECRET_KEY,

    public: {
      // Доступна и на сервере, и в браузере — попадает в бандл
      stripePublishableKey: process.env.NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
    },
  },
})

Доступ в серверном коде:

// server/api/checkout.ts
export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const stripe = new Stripe(config.stripeSecretKey) // только сервер
  // ...
})

Доступ в компонентах (только публичные значения):

// components/PaymentButton.vue
const config = useRuntimeConfig()
const publishableKey = config.public.stripePublishableKey // безопасно
Расположение в конфигеГде доступнаВидна в бандле
runtimeConfig.public.*Браузер + СерверДа
runtimeConfig.* (корень)Только серверНет

Angular

Angular компилируется целиком в статический JS — серверного рантайма в стандартном Angular-приложении нет. Всё, что попало в environment.ts, окажется в бандле.

// Неправильно — попадёт в бандл
// src/environments/environment.prod.ts
export const environment = {
  production: true,
  stripeSecretKey: 'sk_live_abc123', // виден всем
}

Правильный подход — никогда не класть секретные ключи в Angular-файлы. Все чувствительные запросы проксируйте через бэкенд:

// Неправильно — прямой вызов Stripe из Angular
createPayment() {
  return this.http.post('https://api.stripe.com/v1/charges', data, {
    headers: { Authorization: `Bearer ${environment.stripeSecretKey}` }
  })
}

// Правильно — вызов своего бэкенда, который хранит ключ
createPayment(cart: Cart) {
  return this.http.post('/api/checkout', cart)
}

Для Angular Universal (SSR) секреты можно хранить в серверном Express-коде:

// server.ts (Express)
app.post('/api/checkout', async (req, res) => {
  const stripe = new Stripe(process.env['STRIPE_SECRET_KEY']!)
  // ...
})

PHP

PHP выполняется на сервере — секреты не попадают в браузер, если вы явно не выводите их в HTML или JavaScript.

Хранение секретов в файле, доступном через веб:

// Неправильно — config.php в публичной папке
$apiKey = 'sk_live_abc123';

// Правильно — .env выше корня сайта
// .env в /var/www/ (не в /var/www/html/)
STRIPE_SECRET_KEY=sk_live_abc123

Вывод секретов в JavaScript:

// Неправильно — ключ виден в исходнике страницы
<script>
  const key = "<?= $stripeSecretKey ?>";
</script>

// Правильно — вызывайте PHP-API из JS, ключ не выводите
<script>
  fetch('/api/checkout.php', { method: 'POST', body: JSON.stringify(cart) })
</script>

Заблокируйте прямой доступ к .env:

# .htaccess
<FilesMatch "^\.env">
  Require all denied
</FilesMatch>

WordPress

Не кладите API-ключи прямо в файлы темы или functions.php:

// Неправильно — ключ виден всем у кого есть FTP-доступ и в git
define('STRIPE_SECRET_KEY', 'sk_live_abc123');

// Правильно — используйте wp-config.php (выше корня) или .env-плагин
// wp-config.php
define('STRIPE_SECRET_KEY', getenv('STRIPE_SECRET_KEY'));

Плагины, которые просят API-ключ в панели администратора, хранят его в базе данных. Это приемлемо, если у вашей админки сильный пароль и она не доступна публично.

Если кастомный Gutenberg-блок или тема вызывают API с фронтенда — проксируйте запрос через REST API WordPress или admin-ajax.php, чтобы ключ остался на сервере.


Tilda

Tilda не поддерживает серверный код. Если нужно вызывать закрытый API, два варианта:

  1. Создайте простой прокси на отдельном сервере (Node.js, PHP, Cloudflare Worker), который хранит ключ, и вызывайте его из Tilda.
  2. Используйте только API, рассчитанные на прямой вызов из браузера с ограничением по домену (например, Google Maps с HTTP referrer restrictions).

Не вставляйте секретные ключи в HTML/JS-блоки Tilda — они будут видны в исходнике страницы.


Ключи, которые всё же нужны в браузере

Ограничьте их в панели провайдера. В Google Cloud Console: Credentials -> API key -> Application restrictions -> HTTP referrers -> добавьте только свой домен. Утёкший ключ с ограничением домена можно использовать только с вашего сайта.


После исправлений

Пересоберите и снова поищите:

npm run build
grep -r "sk_live\|AIza\|ghp_" .next/static/

Отзовите все ключи, которые были публично доступны. Ключ, однажды попавший в публичный бандл, следует считать скомпрометированным.

Как устранить утечки API-ключей в JS-бандлах | vibeblame