Почему секреты во фронтенде всегда видны
Клиентский 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, два варианта:
- Создайте простой прокси на отдельном сервере (Node.js, PHP, Cloudflare Worker), который хранит ключ, и вызывайте его из Tilda.
- Используйте только 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/
Отзовите все ключи, которые были публично доступны. Ключ, однажды попавший в публичный бандл, следует считать скомпрометированным.